PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.5.2
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.5.2
3.5.9 3.5.8 3.5.7 3.5.6 3.5.5 3.5.4 3.5.3 3.5.2 3.5.1 3.5.0 3.4.9 3.4.8 3.4.7 0.2.1 1.6.91 0.2.2 1.6.92 0.2.3 1.6.93 0.2.4 1.6.94 0.2.5 1.6.95 0.2.6 1.6.96 0.2.7 1.6.97 0.2.8 1.6.98 0.2.9 1.6.99 0.3.0 1.7.0 0.3.1 1.7.1 0.3.2 1.7.2 0.3.3 1.7.3 0.3.4 1.7.4 0.3.5 1.7.5 0.3.6 1.7.6 0.4.0 1.7.7 0.4.1 1.7.8 0.4.2 1.7.9 0.4.3 1.8.0 0.4.4 1.8.1 0.4.5 1.8.2 0.4.6 1.8.3 0.4.7 1.8.4 0.4.8 1.8.5 0.4.9 1.8.6 0.5.0 1.8.7 0.5.1 1.8.8 0.5.2 1.8.9 0.5.3 1.9.0 0.5.4 1.9.1 0.5.5 1.9.2 0.5.6 1.9.3 0.5.7 1.9.4 0.5.8 1.9.5 0.5.9 1.9.6 0.6.0 1.9.7 0.6.1 1.9.8 0.6.2 1.9.81 0.6.3 1.9.82 0.6.4 1.9.83 0.6.5 1.9.84 0.6.6 1.9.85 0.6.7 1.9.86 0.6.8 1.9.87 0.6.9 1.9.88 0.7.0 1.9.89 0.7.1 1.9.90 0.7.2 1.9.91 0.7.3 1.9.92 0.7.4 1.9.93 0.7.5 1.9.94 0.7.6 1.9.95 0.7.7 1.9.96 0.7.8 1.9.97 0.7.9 1.9.98 0.8.0 1.9.99 0.8.1 2.0.0 0.8.2 2.0.1 0.8.3 2.0.2 0.8.4 2.0.3 0.8.5 2.0.4 0.8.6 2.0.5 0.8.7 2.0.6 0.8.8 2.0.7 0.8.9 2.0.8 0.9.0 2.0.9 0.9.2 2.1.0 0.9.3 2.1.1 0.9.4 2.1.2 0.9.5 2.1.3 0.9.6 2.1.4 0.9.7 2.1.5 0.9.8 2.1.6 0.9.81 2.1.7 0.9.82 2.1.8 0.9.83 2.1.9 0.9.84 2.2.0 0.9.85 2.2.1 0.9.86 2.2.2 0.9.87 2.2.3 0.9.88 2.2.4 0.9.89 2.2.5 0.9.9 2.2.51 0.9.91 2.2.52 0.9.92 2.2.53 0.9.93 2.2.54 0.9.94 2.2.56 0.9.95 2.2.57 0.9.96 2.2.6 0.9.97 2.2.60 0.9.98 2.2.61 0.9.99 2.2.62 1.0.0 2.2.63 1.0.01 2.2.70 1.0.1 2.2.80 1.0.2 2.2.81 1.0.3 2.2.90 1.0.4 2.2.91 1.0.5 2.2.92 1.0.6 2.2.93 1.0.7 2.2.94 1.0.8 2.2.95 1.0.9 2.3.0 1.1.0 2.3.1 1.1.1 2.3.2 1.1.2 2.3.3 1.1.3 2.3.4 1.1.4 2.3.5 1.1.5 2.3.6 1.1.6 2.3.7 1.1.7 2.3.8 1.1.8 2.3.9 1.1.9 2.4.0 1.2.0 2.4.1 1.2.1 2.4.2 1.2.2 2.4.3 1.2.21 2.4.4 1.2.3 2.4.5 1.2.30 2.4.6 1.3.0 2.4.7 1.3.1 2.4.8 1.3.2 2.4.9 1.3.3 2.5.0 1.3.31 2.5.1 1.3.32 2.5.2 1.3.33 2.5.3 1.3.34 2.5.4 1.3.35 2.5.5 1.3.36 2.5.6 1.3.37 2.5.7 1.3.38 2.5.8 1.3.39 2.5.9 1.3.40 2.6.0 1.3.41 2.6.1 1.3.42 2.6.2 1.3.43 2.6.3 1.3.44 2.6.5 1.3.45 2.6.6 1.3.46 2.6.7 1.3.47 2.6.8 1.3.48 2.6.9 1.3.49 2.7.0 1.3.50 2.7.1 1.3.51 2.7.2 1.3.52 2.7.3 1.3.53 2.7.4 1.3.54 2.7.5 1.3.56 2.7.6 1.3.57 2.7.7 1.3.58 2.7.8 1.3.59 2.7.9 1.3.60 2.8.0 1.3.61 2.8.1 1.3.62 2.8.2 1.3.63 2.8.3 1.3.64 2.8.4 1.3.65 2.8.5 1.3.66 2.8.6 1.3.67 2.8.7 1.3.68 2.8.8 1.3.69 2.8.9 1.3.70 2.9.0 1.3.71 2.9.1 1.3.72 2.9.2 1.3.73 2.9.3 1.3.74 2.9.4 1.3.75 2.9.5 1.3.76 2.9.6 1.3.77 2.9.7 1.3.78 2.9.8 1.3.79 2.9.9 1.3.80 3.0.0 1.3.81 3.0.1 1.3.82 3.0.2 1.3.83 3.0.3 1.3.84 3.0.4 1.3.85 3.0.5 1.3.86 3.0.6 1.3.87 3.0.7 1.3.88 3.0.8 1.3.89 3.0.9 1.3.90 3.1.0 1.3.91 3.1.1 1.3.92 3.1.2 1.3.93 3.1.3 1.3.94 3.1.4 1.3.95 3.1.5 1.3.96 3.1.6 1.3.97 3.1.7 1.3.98 3.1.8 1.3.99 3.1.9 1.4.0 3.2.0 1.4.1 3.2.1 1.4.2 3.2.2 1.4.3 3.2.3 1.4.4 3.2.4 1.4.5 3.2.5 1.4.6 3.2.6 1.4.7 3.2.7 1.4.8 3.2.8 1.4.9 3.2.9 1.5.0 3.3.0 1.5.1 3.3.1 1.5.2 3.3.2 1.5.3 3.3.3 1.5.4 3.3.4 1.5.5 3.3.5 1.5.6 3.3.6 1.5.7 3.3.7 1.5.8 3.3.8 1.5.9 3.3.9 1.6.0 3.4.0 1.6.1 3.4.1 1.6.2 3.4.2 1.6.3 3.4.3 1.6.5 3.4.4 1.6.51 3.4.5 1.6.52 3.4.6 1.6.53 1.6.54 1.6.55 1.6.56 1.6.57 1.6.58 1.6.59 1.6.60 1.6.61 1.6.62 1.6.63 1.6.64 1.6.65 1.6.66 1.6.67 1.6.68 trunk 1.6.69 0.0.1 1.6.70 0.0.2 1.6.71 0.0.3 1.6.72 0.0.4 1.6.73 0.0.5 1.6.74 0.0.6 1.6.75 0.0.7 1.6.76 0.0.8 1.6.77 0.0.9 1.6.78 0.1.0 1.6.79 0.1.1 1.6.81 0.1.2 1.6.82 0.1.3 1.6.83 0.1.4 1.6.84 0.1.5 1.6.85 0.1.6 1.6.86 0.1.7 1.6.87 0.1.8 1.6.88 0.1.9 1.6.89 0.2.0 1.6.90
ai-engine / labs / mcp-core.php
ai-engine / labs Last commit date
mcp-core.php 1 month ago mcp-oauth.php 1 month ago mcp-rest.php 1 month ago mcp.conf 1 year ago mcp.js 8 months ago mcp.md 8 months ago mcp.php 1 month ago wpai-connectors.php 1 month ago wpai-gateway-availability.php 2 months ago wpai-gateway-directory.php 2 months ago wpai-gateway-image-model.php 2 months ago wpai-gateway-model.php 2 months ago wpai-gateway-providers.php 2 months ago wpai-gateway.php 2 months ago
mcp-core.php
1823 lines
1 <?php
2
3 class Meow_MWAI_Labs_MCP_Core {
4 private $core = null;
5
6 #region Initialize
7 public function __construct( $core ) {
8 $this->core = $core;
9 add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
10 }
11 public function rest_api_init() {
12 add_filter( 'mwai_mcp_tools', [ $this, 'register_rest_tools' ] );
13 add_filter( 'mwai_mcp_callback', [ $this, 'handle_call' ], 10, 4 );
14 }
15 #endregion
16
17 #region Helpers
18 private function add_result_text( array &$r, string $text ): void {
19 if ( !isset( $r['result']['content'] ) ) {
20 $r['result']['content'] = [];
21 }
22 $r['result']['content'][] = [ 'type' => 'text', 'text' => $text ];
23 }
24 private function clean_html( string $v ): string {
25 return wp_kses_post( wp_unslash( $v ) );
26 }
27 private function post_excerpt( WP_Post $p ): string {
28 return wp_trim_words( wp_strip_all_tags( $p->post_excerpt ?: $p->post_content ), 55 );
29 }
30 private function empty_schema(): array {
31 return [ 'type' => 'object', 'properties' => (object) [] ];
32 }
33
34 /**
35 * Bust post caches after a write so a follow-up wp_get_post in the next request
36 * returns fresh data on sites with persistent object caches (Redis, Memcached) or
37 * page caches (LiteSpeed, WP Rocket, Cloudflare, etc.). wp_insert_post / wp_update_post
38 * call clean_post_cache themselves; this is idempotent and also fans out third-party
39 * purge hooks plus a generic mwai_mcp_post_changed action so sites can wire their own.
40 *
41 * Per-request dedupe: agentic clients often hit the same post several times in quick
42 * succession (e.g. wp_alter_post twice on the same page within the same JSON-RPC call),
43 * which would multiply expensive third-party purges (Cloudflare global, Algolia reindex).
44 * We keep a static set of post IDs already busted in this PHP request and short-circuit
45 * repeats. The $context array is forwarded to mwai_mcp_post_changed so handlers can
46 * coalesce or defer purges across requests on their own (e.g. flush at end of batch).
47 */
48 private function bust_post_cache( int $post_id, array $context = [] ): void {
49 if ( $post_id <= 0 ) {
50 return;
51 }
52 static $already_busted = [];
53 if ( isset( $already_busted[ $post_id ] ) ) {
54 return;
55 }
56 $already_busted[ $post_id ] = true;
57
58 clean_post_cache( $post_id );
59 $context = wp_parse_args( $context, [
60 'source' => 'mcp',
61 'tool' => null,
62 'batch' => false,
63 ] );
64 do_action( 'mwai_mcp_post_changed', $post_id, $context );
65 do_action( 'litespeed_purge_post', $post_id );
66 if ( function_exists( 'rocket_clean_post' ) ) {
67 rocket_clean_post( $post_id );
68 }
69 }
70 #endregion
71
72 #region Tools Definitions
73 private function tools(): array {
74 return [
75
76 /* -------- Plugins -------- */
77 'wp_list_plugins' => [
78 'name' => 'wp_list_plugins',
79 'description' => 'List installed plugins (returns array of {Name, Version}).',
80 'inputSchema' => [
81 'type' => 'object',
82 'properties' => [ 'search' => [ 'type' => 'string' ] ],
83 ],
84 'accessLevel' => 'read',
85 ],
86
87 /* -------- Users -------- */
88 'wp_get_users' => [
89 'name' => 'wp_get_users',
90 'description' => 'Retrieve users (fields: ID, user_login, display_name, roles). If no limit supplied, returns 10. `paged` ignored if `offset` is used.',
91 'inputSchema' => [
92 'type' => 'object',
93 'properties' => [
94 'search' => [ 'type' => 'string' ],
95 'role' => [ 'type' => 'string' ],
96 'limit' => [ 'type' => 'integer' ],
97 'offset' => [ 'type' => 'integer' ],
98 'paged' => [ 'type' => 'integer' ],
99 ],
100 ],
101 'accessLevel' => 'admin',
102 ],
103 'wp_create_user' => [
104 'name' => 'wp_create_user',
105 'description' => 'Create a user. Requires user_login and user_email. Optional: user_pass (random if omitted), display_name, role.',
106 'inputSchema' => [
107 'type' => 'object',
108 'properties' => [
109 'user_login' => [ 'type' => 'string' ],
110 'user_email' => [ 'type' => 'string' ],
111 'user_pass' => [ 'type' => 'string' ],
112 'display_name' => [ 'type' => 'string' ],
113 'role' => [ 'type' => 'string' ],
114 ],
115 'required' => [ 'user_login', 'user_email' ],
116 ],
117 'accessLevel' => 'admin',
118 ],
119 'wp_update_user' => [
120 'name' => 'wp_update_user',
121 'description' => 'Update a user – pass ID plus a “fields” object (user_email, display_name, user_pass, role).',
122 'inputSchema' => [
123 'type' => 'object',
124 'properties' => [
125 'ID' => [ 'type' => 'integer' ],
126 'fields' => [
127 'type' => 'object',
128 'properties' => [
129 'user_email' => [ 'type' => 'string' ],
130 'display_name' => [ 'type' => 'string' ],
131 'user_pass' => [ 'type' => 'string' ],
132 'role' => [ 'type' => 'string' ],
133 ],
134 'additionalProperties' => true
135 ],
136 ],
137 'required' => [ 'ID' ],
138 ],
139 'accessLevel' => 'admin',
140 ],
141
142 /* -------- Comments -------- */
143 'wp_get_comments' => [
144 'name' => 'wp_get_comments',
145 'description' => 'Retrieve comments (fields: comment_ID, comment_post_ID, comment_author, comment_content, comment_date, comment_approved). Returns 10 by default.',
146 'inputSchema' => [
147 'type' => 'object',
148 'properties' => [
149 'post_id' => [ 'type' => 'integer' ],
150 'status' => [ 'type' => 'string' ],
151 'search' => [ 'type' => 'string' ],
152 'limit' => [ 'type' => 'integer' ],
153 'offset' => [ 'type' => 'integer' ],
154 'paged' => [ 'type' => 'integer' ],
155 ],
156 ],
157 'accessLevel' => 'read',
158 ],
159 'wp_create_comment' => [
160 'name' => 'wp_create_comment',
161 'description' => 'Insert a comment. Requires post_id and comment_content. Optional author, author_email, author_url.',
162 'inputSchema' => [
163 'type' => 'object',
164 'properties' => [
165 'post_id' => [ 'type' => 'integer' ],
166 'comment_content' => [ 'type' => 'string' ],
167 'comment_author' => [ 'type' => 'string' ],
168 'comment_author_email' => [ 'type' => 'string' ],
169 'comment_author_url' => [ 'type' => 'string' ],
170 'comment_approved' => [ 'type' => 'string' ],
171 ],
172 'required' => [ 'post_id', 'comment_content' ],
173 ],
174 'accessLevel' => 'write',
175 ],
176 'wp_update_comment' => [
177 'name' => 'wp_update_comment',
178 'description' => 'Update a comment – pass comment_ID plus fields (comment_content, comment_approved).',
179 'inputSchema' => [
180 'type' => 'object',
181 'properties' => [
182 'comment_ID' => [ 'type' => 'integer' ],
183 'fields' => [
184 'type' => 'object',
185 'properties' => [
186 'comment_content' => [ 'type' => 'string' ],
187 'comment_approved' => [ 'type' => 'string' ],
188 ],
189 'additionalProperties' => true
190 ],
191 ],
192 'required' => [ 'comment_ID' ],
193 ],
194 'accessLevel' => 'write',
195 ],
196 'wp_delete_comment' => [
197 'name' => 'wp_delete_comment',
198 'description' => 'Delete a comment. `force` true bypasses trash.',
199 'inputSchema' => [
200 'type' => 'object',
201 'properties' => [
202 'comment_ID' => [ 'type' => 'integer' ],
203 'force' => [ 'type' => 'boolean' ],
204 ],
205 'required' => [ 'comment_ID' ],
206 ],
207 'accessLevel' => 'admin',
208 ],
209
210 /* -------- Options -------- */
211 'wp_get_option' => [
212 'name' => 'wp_get_option',
213 'description' => 'Get a single WordPress option value (scalar or array) by key.',
214 'inputSchema' => [
215 'type' => 'object',
216 'properties' => [ 'key' => [ 'type' => 'string' ] ],
217 'required' => [ 'key' ],
218 ],
219 'accessLevel' => 'admin',
220 ],
221 'wp_update_option' => [
222 'name' => 'wp_update_option',
223 'description' => 'Create or update a WordPress option (JSON-serialised if necessary).',
224 'inputSchema' => [
225 'type' => 'object',
226 'properties' => [
227 'key' => [ 'type' => 'string' ],
228 'value' => [ 'type' => [ 'string', 'number', 'boolean', 'object', 'array' ] ],
229 ],
230 'required' => [ 'key', 'value' ],
231 ],
232 'accessLevel' => 'admin',
233 ],
234
235 /* -------- Counts -------- */
236 'wp_count_posts' => [
237 'name' => 'wp_count_posts',
238 'description' => 'Return counts of posts by status. Optional post_type (default post).',
239 'inputSchema' => [
240 'type' => 'object',
241 'properties' => [ 'post_type' => [ 'type' => 'string' ] ],
242 ],
243 'accessLevel' => 'read',
244 ],
245 'wp_count_terms' => [
246 'name' => 'wp_count_terms',
247 'description' => 'Return total number of terms in a taxonomy.',
248 'inputSchema' => [
249 'type' => 'object',
250 'properties' => [ 'taxonomy' => [ 'type' => 'string' ] ],
251 'required' => [ 'taxonomy' ],
252 ],
253 'accessLevel' => 'read',
254 ],
255 'wp_count_media' => [
256 'name' => 'wp_count_media',
257 'description' => 'Return number of attachments (optionally after/before date).',
258 'inputSchema' => [
259 'type' => 'object',
260 'properties' => [
261 'after' => [ 'type' => 'string' ],
262 'before' => [ 'type' => 'string' ],
263 ],
264 ],
265 'accessLevel' => 'read',
266 ],
267
268 /* -------- Post-types -------- */
269 'wp_get_post_types' => [
270 'name' => 'wp_get_post_types',
271 'description' => 'List public post types (key, label).',
272 'inputSchema' => $this->empty_schema(),
273 'accessLevel' => 'read',
274 ],
275
276 /* -------- Posts -------- */
277 'wp_get_posts' => [
278 'name' => 'wp_get_posts',
279 'description' => 'Retrieve posts (fields: ID, title, status, excerpt, link). No full content. **If no limit is supplied it returns 10 posts by default.** `paged` is ignored if `offset` is used.',
280 'inputSchema' => [
281 'type' => 'object',
282 'properties' => [
283 'post_type' => [ 'type' => 'string' ],
284 'post_status' => [ 'type' => 'string' ],
285 'search' => [ 'type' => 'string' ],
286 'after' => [ 'type' => 'string' ],
287 'before' => [ 'type' => 'string' ],
288 'limit' => [ 'type' => 'integer' ],
289 'offset' => [ 'type' => 'integer' ],
290 'paged' => [ 'type' => 'integer' ],
291 ],
292 ],
293 'accessLevel' => 'read',
294 ],
295 'wp_get_post' => [
296 'name' => 'wp_get_post',
297 'description' => 'Get basic post data by ID: title, content, status, dates, permalink. Reads through the WordPress object cache; if you just wrote with wp_create_post / wp_update_post / wp_alter_post, the write tools bust caches automatically so a follow-up read returns fresh data. For complete data including all meta and terms, use wp_get_post_snapshot instead.',
298 'inputSchema' => [
299 'type' => 'object',
300 'properties' => [ 'ID' => [ 'type' => 'integer' ] ],
301 'required' => [ 'ID' ],
302 ],
303 'accessLevel' => 'read',
304 ],
305 'wp_get_post_snapshot' => [
306 'name' => 'wp_get_post_snapshot',
307 'description' => 'Get complete post data in ONE call: all post fields, all meta, all terms/taxonomies, featured image, and author. Use this for WooCommerce products, events, or any post type where you need full context. Reduces 10-20 API calls to just 1. Returns structured JSON with post, meta, terms, thumbnail, and author keys.',
308 'inputSchema' => [
309 'type' => 'object',
310 'properties' => [
311 'ID' => [ 'type' => 'integer', 'description' => 'Post ID' ],
312 'include' => [
313 'type' => 'array',
314 'description' => 'Optional: fields to include (default: all). Options: meta, terms, thumbnail, author',
315 'items' => [ 'type' => 'string' ],
316 ],
317 'exclude' => [
318 'type' => 'array',
319 'description' => 'Optional: fields to exclude from post data. Options: content (useful for posts with huge content like many galleries)',
320 'items' => [ 'type' => 'string' ],
321 ],
322 ],
323 'required' => [ 'ID' ],
324 ],
325 'accessLevel' => 'read',
326 ],
327 'wp_create_post' => [
328 'name' => 'wp_create_post',
329 'description' => 'Create a new post, page, or any custom post type. post_title is required. Markdown is accepted in post_content. post_status defaults to "draft" and post_type defaults to "post" – pass post_type: "page" for a page, or any registered CPT slug (product, event, etc.). Set categories later with wp_add_post_terms; meta_input is an associative array of custom-field key/value pairs.',
330 'inputSchema' => [
331 'type' => 'object',
332 'properties' => [
333 'post_title' => [ 'type' => 'string' ],
334 'post_content' => [ 'type' => 'string' ],
335 'post_excerpt' => [ 'type' => 'string' ],
336 'post_status' => [ 'type' => 'string' ],
337 'post_type' => [ 'type' => 'string' ],
338 'post_name' => [ 'type' => 'string' ],
339 'meta_input' => [ 'type' => 'object', 'description' => 'Associative array of custom fields.' ],
340 ],
341 'required' => [ 'post_title' ],
342 ],
343 'accessLevel' => 'write',
344 ],
345 'wp_update_post' => [
346 'name' => 'wp_update_post',
347 'description' => 'Update post fields and/or meta in ONE call. Pass ID + "fields" object (post_title, post_content, post_status, etc.) and/or "meta_input" object for custom fields. Efficient for WooCommerce products: update title + price + stock together. Note: post_category REPLACES categories; use wp_add_post_terms to append instead. Use schedule_for to easily schedule posts.',
348 'inputSchema' => [
349 'type' => 'object',
350 'properties' => [
351 'ID' => [ 'type' => 'integer', 'description' => 'The ID of the post to update.' ],
352 'fields' => [
353 'type' => 'object',
354 'properties' => [
355 'post_title' => [ 'type' => 'string' ],
356 'post_content' => [ 'type' => 'string' ],
357 'post_status' => [ 'type' => 'string' ],
358 'post_name' => [ 'type' => 'string' ],
359 'post_excerpt' => [ 'type' => 'string' ],
360 'post_category' => [ 'type' => 'array', 'items' => [ 'type' => 'integer' ] ],
361 ],
362 'additionalProperties' => true
363 ],
364 'meta_input' => [
365 'type' => 'object',
366 'description' => 'Associative array of custom fields.'
367 ],
368 'schedule_for' => [
369 'type' => 'string',
370 'description' => 'Schedule post for future publication. Provide local datetime (e.g., "2026-02-02 09:00:00"). Automatically sets status to "future" and calculates GMT from WordPress timezone.'
371 ],
372 ],
373 'required' => [ 'ID' ],
374 ],
375 'accessLevel' => 'write',
376 ],
377 'wp_delete_post' => [
378 'name' => 'wp_delete_post',
379 'description' => 'Delete, trash, or remove a post, page, or any custom post type by ID. Without force, the post is moved to trash (can be restored). With force: true, the post is permanently destroyed (bypasses trash, irreversible). Works for posts, pages, products, events, attachments, or any registered CPT.',
380 'inputSchema' => [
381 'type' => 'object',
382 'properties' => [
383 'ID' => [ 'type' => 'integer' ],
384 'force' => [ 'type' => 'boolean' ],
385 ],
386 'required' => [ 'ID' ],
387 ],
388 'accessLevel' => 'admin',
389 ],
390 'wp_alter_post' => [
391 'name' => 'wp_alter_post',
392 'description' => 'Search-and-replace inside a post field without re-uploading the entire content. Efficient for making small edits to long content. Supports regex patterns (PHP-PCRE with delimiters like /pattern/i).',
393 'inputSchema' => [
394 'type' => 'object',
395 'properties' => [
396 'ID' => [ 'type' => 'integer', 'description' => 'Post ID.' ],
397 'field' => [ 'type' => 'string', 'description' => 'Field to modify: post_content, post_excerpt, or post_title.' ],
398 'search' => [ 'type' => 'string', 'description' => 'Text or regex pattern to search for.' ],
399 'replace' => [ 'type' => 'string', 'description' => 'Replacement text.' ],
400 'regex' => [ 'type' => 'boolean', 'description' => 'Treat search as regex pattern (default: false).' ],
401 ],
402 'required' => [ 'ID', 'field', 'search', 'replace' ],
403 ],
404 'accessLevel' => 'write',
405 ],
406
407 /* -------- Post-meta -------- */
408 'wp_get_post_meta' => [
409 'name' => 'wp_get_post_meta',
410 'description' => 'Get specific post meta field(s). Provide "key" to fetch a single value; omit to fetch all custom fields. If you need ALL meta along with post data and terms, use wp_get_post_snapshot instead for efficiency.',
411 'inputSchema' => [
412 'type' => 'object',
413 'properties' => [
414 'ID' => [ 'type' => 'integer' ],
415 'key' => [ 'type' => 'string' ],
416 ],
417 'required' => [ 'ID' ],
418 ],
419 'accessLevel' => 'read',
420 ],
421 'wp_update_post_meta' => [
422 'name' => 'wp_update_post_meta',
423 'description' => 'Update post meta efficiently. Use "meta" object to update MULTIPLE fields at once (e.g., {_price: "19.99", _stock: "50", _sku: "WIDGET"}), or use "key"+"value" for a single field. Essential for WooCommerce products and custom post types.',
424 'inputSchema' => [
425 'type' => 'object',
426 'properties' => [
427 'ID' => [ 'type' => 'integer' ],
428 'meta' => [ 'type' => 'object', 'description' => 'Key/value pairs to set. Alternative: provide "key" + "value".' ],
429 'key' => [ 'type' => 'string' ],
430 'value' => [ 'type' => [ 'string', 'number', 'boolean' ] ],
431 ],
432 'required' => [ 'ID' ],
433 ],
434 'accessLevel' => 'write',
435 ],
436 'wp_delete_post_meta' => [
437 'name' => 'wp_delete_post_meta',
438 'description' => 'Delete custom field(s) from a post. Provide value to remove a single row; omit value to delete all rows for the key.',
439 'inputSchema' => [
440 'type' => 'object',
441 'properties' => [
442 'ID' => [ 'type' => 'integer' ],
443 'key' => [ 'type' => 'string' ],
444 'value' => [ 'type' => [ 'string', 'number', 'boolean' ] ],
445 ],
446 'required' => [ 'ID', 'key' ],
447 ],
448 'accessLevel' => 'admin',
449 ],
450
451 /* -------- Featured image -------- */
452 'wp_set_featured_image' => [
453 'name' => 'wp_set_featured_image',
454 'description' => 'Attach or remove a featured image (thumbnail) for a post/page. Provide media_id to attach, omit or null to remove.',
455 'inputSchema' => [
456 'type' => 'object',
457 'properties' => [
458 'post_id' => [ 'type' => 'integer' ],
459 'media_id' => [ 'type' => 'integer' ],
460 ],
461 'required' => [ 'post_id' ],
462 ],
463 'accessLevel' => 'write',
464 ],
465
466 /* -------- Taxonomies / Terms -------- */
467 'wp_get_taxonomies' => [
468 'name' => 'wp_get_taxonomies',
469 'description' => 'List taxonomies for a post type.',
470 'inputSchema' => [
471 'type' => 'object',
472 'properties' => [ 'post_type' => [ 'type' => 'string' ] ],
473 ],
474 'accessLevel' => 'read',
475 ],
476 'wp_get_terms' => [
477 'name' => 'wp_get_terms',
478 'description' => 'List terms of a taxonomy.',
479 'inputSchema' => [
480 'type' => 'object',
481 'properties' => [
482 'taxonomy' => [ 'type' => 'string' ],
483 'search' => [ 'type' => 'string' ],
484 'parent' => [ 'type' => 'integer' ],
485 'limit' => [ 'type' => 'integer' ],
486 ],
487 'required' => [ 'taxonomy' ],
488 ],
489 'accessLevel' => 'read',
490 ],
491 'wp_create_term' => [
492 'name' => 'wp_create_term',
493 'description' => 'Create a term.',
494 'inputSchema' => [
495 'type' => 'object',
496 'properties' => [
497 'taxonomy' => [ 'type' => 'string' ],
498 'term_name' => [ 'type' => 'string' ],
499 'slug' => [ 'type' => 'string' ],
500 'description' => [ 'type' => 'string' ],
501 'parent' => [ 'type' => 'integer' ],
502 ],
503 'required' => [ 'taxonomy', 'term_name' ],
504 ],
505 'accessLevel' => 'write',
506 ],
507 'wp_update_term' => [
508 'name' => 'wp_update_term',
509 'description' => 'Update a term.',
510 'inputSchema' => [
511 'type' => 'object',
512 'properties' => [
513 'term_id' => [ 'type' => 'integer' ],
514 'taxonomy' => [ 'type' => 'string' ],
515 'name' => [ 'type' => 'string' ],
516 'slug' => [ 'type' => 'string' ],
517 'description' => [ 'type' => 'string' ],
518 'parent' => [ 'type' => 'integer' ],
519 ],
520 'required' => [ 'term_id', 'taxonomy' ],
521 ],
522 'accessLevel' => 'write',
523 ],
524 'wp_delete_term' => [
525 'name' => 'wp_delete_term',
526 'description' => 'Delete a term.',
527 'inputSchema' => [
528 'type' => 'object',
529 'properties' => [
530 'term_id' => [ 'type' => 'integer' ],
531 'taxonomy' => [ 'type' => 'string' ],
532 ],
533 'required' => [ 'term_id', 'taxonomy' ],
534 ],
535 'accessLevel' => 'admin',
536 ],
537 'wp_get_post_terms' => [
538 'name' => 'wp_get_post_terms',
539 'description' => 'Get terms attached to a post.',
540 'inputSchema' => [
541 'type' => 'object',
542 'properties' => [
543 'ID' => [ 'type' => 'integer' ],
544 'taxonomy' => [ 'type' => 'string' ],
545 ],
546 'required' => [ 'ID' ],
547 ],
548 'accessLevel' => 'read',
549 ],
550 'wp_add_post_terms' => [
551 'name' => 'wp_add_post_terms',
552 'description' => 'Attach or replace terms for a post. Set "append=true" to ADD terms to existing ones, or "append=false" (default) to REPLACE all terms. Use for categories, tags, or WooCommerce attributes (pa_color, pa_size, etc.).',
553 'inputSchema' => [
554 'type' => 'object',
555 'properties' => [
556 'ID' => [ 'type' => 'integer' ],
557 'taxonomy' => [ 'type' => 'string' ],
558 'terms' => [ 'type' => 'array', 'items' => [ 'type' => 'integer' ] ],
559 'append' => [ 'type' => 'boolean' ],
560 ],
561 'required' => [ 'ID', 'terms' ],
562 ],
563 'accessLevel' => 'write',
564 ],
565
566 /* -------- Media -------- */
567 'wp_get_media' => [
568 'name' => 'wp_get_media',
569 'description' => 'List media items.',
570 'inputSchema' => [
571 'type' => 'object',
572 'properties' => [
573 'search' => [ 'type' => 'string' ],
574 'after' => [ 'type' => 'string' ],
575 'before' => [ 'type' => 'string' ],
576 'limit' => [ 'type' => 'integer' ],
577 ],
578 ],
579 'accessLevel' => 'read',
580 ],
581 'wp_upload_media' => [
582 'name' => 'wp_upload_media',
583 'description' => 'Upload a file to the WordPress Media Library. Provide either a url (WordPress will download it) or base64-encoded content with a filename. Base64 mode is useful for local files but doubles the payload size — keep files under a few MB to avoid memory or timeout issues.',
584 'inputSchema' => [
585 'type' => 'object',
586 'properties' => [
587 'url' => [
588 'type' => 'string',
589 'description' => 'URL to download the file from. Use this OR base64/filename.',
590 ],
591 'base64' => [
592 'type' => 'string',
593 'description' => 'Base64-encoded file content. Must be used together with filename.',
594 ],
595 'filename' => [
596 'type' => 'string',
597 'description' => 'Filename with extension (e.g. photo.jpg). Required when using base64.',
598 ],
599 'title' => [ 'type' => 'string' ],
600 'description' => [ 'type' => 'string' ],
601 'alt' => [ 'type' => 'string' ],
602 ],
603 ],
604 'accessLevel' => 'write',
605 ],
606 'wp_upload_request' => [
607 'name' => 'wp_upload_request',
608 'description' => 'Upload a local file to the WordPress Media Library via a temporary upload endpoint. Use this instead of wp_upload_media when you have a local file (not a URL) — passing large base64 strings through MCP is impractical and will likely exceed context limits. Call this tool with the filename and optional metadata; it returns a one-time upload URL. Then use curl to POST the file: curl -X POST -F "file=@/local/path/file.jpg" "<upload_url>". The upload URL expires after 5 minutes and can only be used once.',
609 'inputSchema' => [
610 'type' => 'object',
611 'properties' => [
612 'filename' => [
613 'type' => 'string',
614 'description' => 'Filename with extension (e.g. photo.jpg).',
615 ],
616 'title' => [ 'type' => 'string' ],
617 'description' => [ 'type' => 'string' ],
618 'alt' => [ 'type' => 'string' ],
619 ],
620 'required' => [ 'filename' ],
621 ],
622 'accessLevel' => 'write',
623 ],
624 'wp_update_media' => [
625 'name' => 'wp_update_media',
626 'description' => 'Update attachment meta.',
627 'inputSchema' => [
628 'type' => 'object',
629 'properties' => [
630 'ID' => [ 'type' => 'integer' ],
631 'title' => [ 'type' => 'string' ],
632 'caption' => [ 'type' => 'string' ],
633 'description' => [ 'type' => 'string' ],
634 'alt' => [ 'type' => 'string' ],
635 ],
636 'required' => [ 'ID' ],
637 ],
638 'accessLevel' => 'write',
639 ],
640 'wp_delete_media' => [
641 'name' => 'wp_delete_media',
642 'description' => 'Delete/trash an attachment.',
643 'inputSchema' => [
644 'type' => 'object',
645 'properties' => [
646 'ID' => [ 'type' => 'integer' ],
647 'force' => [ 'type' => 'boolean' ],
648 ],
649 'required' => [ 'ID' ],
650 ],
651 'accessLevel' => 'admin',
652 ],
653
654 /* -------- MWAI Vision / Image -------- */
655 'mwai_vision' => [
656 'name' => 'mwai_vision',
657 'description' => 'Analyze an image via AI Engine Vision.',
658 'inputSchema' => [
659 'type' => 'object',
660 'properties' => [
661 'message' => [ 'type' => 'string' ],
662 'url' => [ 'type' => 'string' ],
663 'path' => [ 'type' => 'string' ],
664 ],
665 'required' => [ 'message' ],
666 ],
667 'accessLevel' => 'read',
668 ],
669 'mwai_image' => [
670 'name' => 'mwai_image',
671 'description' => 'Generate an image with AI Engine and store it in the Media Library. Optional: title, caption, description, alt. Returns { id, url, title, caption, alt }.',
672 'inputSchema' => [
673 'type' => 'object',
674 'properties' => [
675 'message' => [ 'type' => 'string', 'description' => 'Prompt describing the desired image.' ],
676 'postId' => [ 'type' => 'integer', 'description' => 'Optional post ID to attach the image to.' ],
677 'title' => [ 'type' => 'string' ],
678 'caption' => [ 'type' => 'string' ],
679 'description' => [ 'type' => 'string' ],
680 'alt' => [ 'type' => 'string' ],
681 ],
682 'required' => [ 'message' ],
683 ],
684 'accessLevel' => 'write',
685 ],
686
687 ];
688 }
689 #endregion
690
691 #region Tool Registration
692 public function register_rest_tools( array $prev ): array {
693 $tools = $this->tools();
694
695 // All 36 core tools enabled and tested with ChatGPT.
696 // Automatic validation in mcp.php fixes problematic type definitions.
697
698 // Add category and annotations to each tool
699 foreach ( $tools as &$tool ) {
700 if ( !isset( $tool['category'] ) ) {
701 $tool['category'] = 'AI Engine (Core)';
702 }
703
704 // Add MCP tool annotations based on tool name/behavior
705 if ( !isset( $tool['annotations'] ) ) {
706 $name = $tool['name'];
707
708 // Read-only tools (safe, no modifications)
709 $is_readonly = (
710 strpos( $name, 'wp_get_' ) === 0 ||
711 strpos( $name, 'wp_list_' ) === 0 ||
712 strpos( $name, 'wp_count_' ) === 0 ||
713 $name === 'mwai_vision'
714 );
715
716 // Destructive tools (can delete/destroy data)
717 $is_destructive = (
718 strpos( $name, 'wp_delete_' ) === 0 ||
719 $name === 'wp_update_user' // Can change passwords/roles
720 );
721
722 $tool['annotations'] = [
723 'readOnlyHint' => $is_readonly,
724 'destructiveHint' => !$is_readonly && $is_destructive,
725 'openWorldHint' => false, // All operate on closed WordPress system
726 ];
727 }
728 }
729
730 $merged = array_merge( $prev, array_values( $tools ) );
731 return $merged;
732 }
733 #endregion
734
735 #region Callback
736 public function handle_call( $prev, string $tool, array $args, ?int $id ) {
737 // Security check is already done in the MCP auth layer
738 // If we reach here, the user is authorized to use MCP
739 if ( !empty( $prev ) || !isset( $this->tools()[ $tool ] ) ) {
740 return $prev;
741 }
742 return $this->dispatch( $tool, $args, $id );
743 }
744 #endregion
745
746 #region Dispatcher
747 private function dispatch( string $tool, array $a, ?int $id ): array {
748 $r = [ 'jsonrpc' => '2.0', 'id' => $id ];
749
750 switch ( $tool ) {
751
752 /* ===== Users ===== */
753 case 'wp_get_users':
754 $q = [
755 'search' => '*' . esc_attr( $a['search'] ?? '' ) . '*',
756 'role' => $a['role'] ?? '',
757 'number' => max( 1, intval( $a['limit'] ?? 10 ) ),
758 ];
759 if ( isset( $a['offset'] ) ) {
760 $q['offset'] = max( 0, intval( $a['offset'] ) );
761 }
762 if ( isset( $a['paged'] ) ) {
763 $q['paged'] = max( 1, intval( $a['paged'] ) );
764 }
765 $rows = [];
766 foreach ( get_users( $q ) as $u ) {
767 $rows[] = [
768 'ID' => $u->ID,
769 'user_login' => $u->user_login,
770 'display_name' => $u->display_name,
771 'roles' => $u->roles,
772 ];
773 }
774 $this->add_result_text( $r, wp_json_encode( $rows, JSON_PRETTY_PRINT ) );
775 break;
776
777 case 'wp_create_user':
778 $data = [
779 'user_login' => sanitize_user( $a['user_login'] ),
780 'user_email' => sanitize_email( $a['user_email'] ),
781 'user_pass' => $a['user_pass'] ?? wp_generate_password( 12, true ),
782 'display_name' => sanitize_text_field( $a['display_name'] ?? '' ),
783 'role' => sanitize_key( $a['role'] ?? get_option( 'default_role', 'subscriber' ) ),
784 ];
785 $uid = wp_insert_user( $data );
786 if ( is_wp_error( $uid ) ) {
787 $r['error'] = [ 'code' => $uid->get_error_code(), 'message' => $uid->get_error_message() ];
788 }
789 else {
790 $this->add_result_text( $r, 'User created ID ' . $uid );
791 }
792 break;
793
794 case 'wp_update_user':
795 if ( empty( $a['ID'] ) ) {
796 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
797 break;
798 }
799 $upd = [ 'ID' => intval( $a['ID'] ) ];
800 if ( !empty( $a['fields'] ) && is_array( $a['fields'] ) ) {
801 foreach ( $a['fields'] as $k => $v ) {
802 $upd[ $k ] = ( $k === 'role' ) ? sanitize_key( $v ) : sanitize_text_field( $v );
803 }
804 }
805 $u = wp_update_user( $upd );
806 if ( is_wp_error( $u ) ) {
807 $r['error'] = [ 'code' => $u->get_error_code(), 'message' => $u->get_error_message() ];
808 }
809 else {
810 $this->add_result_text( $r, 'User #' . $u . ' updated' );
811 }
812 break;
813
814 /* ===== Comments ===== */
815 case 'wp_get_comments':
816 $args = [
817 'post_id' => isset( $a['post_id'] ) ? intval( $a['post_id'] ) : '',
818 'status' => $a['status'] ?? 'approve',
819 'search' => $a['search'] ?? '',
820 'number' => max( 1, intval( $a['limit'] ?? 10 ) ),
821 ];
822 if ( isset( $a['offset'] ) ) {
823 $args['offset'] = max( 0, intval( $a['offset'] ) );
824 }
825 if ( isset( $a['paged'] ) ) {
826 $args['paged'] = max( 1, intval( $a['paged'] ) );
827 }
828 $list = [];
829 foreach ( get_comments( $args ) as $c ) {
830 $list[] = [
831 'comment_ID' => $c->comment_ID,
832 'comment_post_ID' => $c->comment_post_ID,
833 'comment_author' => $c->comment_author,
834 'comment_content' => wp_trim_words( wp_strip_all_tags( $c->comment_content ), 40 ),
835 'comment_date' => $c->comment_date,
836 'comment_approved' => $c->comment_approved,
837 ];
838 }
839 $this->add_result_text( $r, wp_json_encode( $list, JSON_PRETTY_PRINT ) );
840 break;
841
842 case 'wp_create_comment':
843 if ( empty( $a['post_id'] ) || empty( $a['comment_content'] ) ) {
844 $r['error'] = [ 'code' => -32602, 'message' => 'post_id & comment_content required' ];
845 break;
846 }
847 $ins = [
848 'comment_post_ID' => intval( $a['post_id'] ),
849 'comment_content' => $this->clean_html( $a['comment_content'] ),
850 'comment_author' => sanitize_text_field( $a['comment_author'] ?? '' ),
851 'comment_author_email' => sanitize_email( $a['comment_author_email'] ?? '' ),
852 'comment_author_url' => esc_url_raw( $a['comment_author_url'] ?? '' ),
853 'comment_approved' => $a['comment_approved'] ?? 1,
854 ];
855 $cid = wp_insert_comment( $ins );
856 if ( is_wp_error( $cid ) ) {
857 /** @var WP_Error $cid */
858 $r['error'] = [ 'code' => $cid->get_error_code(), 'message' => $cid->get_error_message() ];
859 }
860 else {
861 $this->add_result_text( $r, 'Comment created ID ' . $cid );
862 }
863 break;
864
865 case 'wp_update_comment':
866 if ( empty( $a['comment_ID'] ) ) {
867 $r['error'] = [ 'code' => -32602, 'message' => 'comment_ID required' ];
868 break;
869 }
870 $c = [ 'comment_ID' => intval( $a['comment_ID'] ) ];
871 if ( !empty( $a['fields'] ) && is_array( $a['fields'] ) ) {
872 foreach ( $a['fields'] as $k => $v ) {
873 $c[ $k ] = ( $k === 'comment_content' ) ? $this->clean_html( $v ) : sanitize_text_field( $v );
874 }
875 }
876 $cid = wp_update_comment( $c, true );
877 if ( is_wp_error( $cid ) ) {
878 $r['error'] = [ 'code' => $cid->get_error_code(), 'message' => $cid->get_error_message() ];
879 }
880 else {
881 $this->add_result_text( $r, 'Comment #' . $cid . ' updated' );
882 }
883 break;
884
885 case 'wp_delete_comment':
886 if ( empty( $a['comment_ID'] ) ) {
887 $r['error'] = [ 'code' => -32602, 'message' => 'comment_ID required' ];
888 break;
889 }
890 $done = wp_delete_comment( intval( $a['comment_ID'] ), !empty( $a['force'] ) );
891 if ( $done ) {
892 $this->add_result_text( $r, 'Comment #' . $a['comment_ID'] . ' deleted' );
893 }
894 else {
895 $r['error'] = [ 'code' => -32603, 'message' => 'Deletion failed' ];
896 }
897 break;
898
899 /* ===== Options ===== */
900 case 'wp_get_option':
901 $val = get_option( sanitize_key( $a['key'] ) );
902 $this->add_result_text( $r, wp_json_encode( $val, JSON_PRETTY_PRINT ) );
903 break;
904
905 case 'wp_update_option':
906 $set = update_option( sanitize_key( $a['key'] ), $a['value'], 'yes' );
907 if ( $set ) {
908 $this->add_result_text( $r, 'Option "' . $a['key'] . '" updated' );
909 }
910 else {
911 $r['error'] = [ 'code' => -32603, 'message' => 'Update failed' ];
912 }
913 break;
914
915 /* ===== Counts ===== */
916 case 'wp_count_posts':
917 $pt = sanitize_key( $a['post_type'] ?? 'post' );
918 $obj = wp_count_posts( $pt );
919 $this->add_result_text( $r, wp_json_encode( $obj, JSON_PRETTY_PRINT ) );
920 break;
921
922 case 'wp_count_terms':
923 $tax = sanitize_key( $a['taxonomy'] );
924 $total = wp_count_terms( $tax, [ 'hide_empty' => false ] );
925 if ( is_wp_error( $total ) ) {
926 $r['error'] = [ 'code' => $total->get_error_code(), 'message' => $total->get_error_message() ];
927 }
928 else {
929 $this->add_result_text( $r, (string) $total );
930 }
931 break;
932
933 case 'wp_count_media':
934 $args = [ 'post_type' => 'attachment', 'post_status' => 'inherit', 'fields' => 'ids' ];
935 $d = [];
936 if ( $a['after'] ?? '' ) {
937 $d['after'] = $a['after'];
938 }
939 if ( $a['before'] ?? '' ) {
940 $d['before'] = $a['before'];
941 }
942 if ( $d ) {
943 $args['date_query'] = [ $d ];
944 }
945 $total = count( get_posts( $args ) );
946 $this->add_result_text( $r, (string) $total );
947 break;
948
949 /* ===== Post-types ===== */
950 case 'wp_get_post_types':
951 $out = [];
952 foreach ( get_post_types( [ 'public' => true ], 'objects' ) as $pt ) {
953 $out[] = [ 'key' => $pt->name, 'label' => $pt->label ];
954 }
955 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
956 break;
957
958 /* ===== Plugins ===== */
959 case 'wp_list_plugins':
960 if ( !function_exists( 'get_plugins' ) ) {
961 require_once ABSPATH . 'wp-admin/includes/plugin.php';
962 }
963 $search = sanitize_text_field( $a['search'] ?? '' );
964 $out = [];
965 foreach ( get_plugins() as $p ) {
966 if ( !$search || stripos( $p['Name'], $search ) !== false ) {
967 $out[] = [ 'Name' => $p['Name'], 'Version' => $p['Version'] ];
968 }
969 }
970 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
971 break;
972
973 /* ===== Posts: list ===== */
974 case 'wp_get_posts':
975 $q = [
976 'post_type' => sanitize_key( $a['post_type'] ?? 'post' ),
977 'post_status' => sanitize_key( $a['post_status'] ?? 'publish' ),
978 's' => sanitize_text_field( $a['search'] ?? '' ),
979 'posts_per_page' => max( 1, intval( $a['limit'] ?? 10 ) ),
980 ];
981 if ( isset( $a['offset'] ) ) {
982 $q['offset'] = max( 0, intval( $a['offset'] ) );
983 }
984 if ( isset( $a['paged'] ) ) {
985 $q['paged'] = max( 1, intval( $a['paged'] ) );
986 }
987 $date = [];
988 if ( $a['after'] ?? '' ) {
989 $date['after'] = $a['after'];
990 }
991 if ( $a['before'] ?? '' ) {
992 $date['before'] = $a['before'];
993 }
994 if ( $date ) {
995 $q['date_query'] = [ $date ];
996 }
997 $rows = [];
998 foreach ( get_posts( $q ) as $p ) {
999 $rows[] = [
1000 'ID' => $p->ID,
1001 'post_title' => $p->post_title,
1002 'post_status' => $p->post_status,
1003 'post_excerpt' => $this->post_excerpt( $p ),
1004 'permalink' => get_permalink( $p ),
1005 ];
1006 }
1007 $this->add_result_text( $r, wp_json_encode( $rows, JSON_PRETTY_PRINT ) );
1008 break;
1009
1010 /* ===== Posts: single ===== */
1011 case 'wp_get_post':
1012 if ( empty( $a['ID'] ) ) {
1013 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
1014 break;
1015 }
1016 $p = get_post( intval( $a['ID'] ) );
1017 if ( !$p ) {
1018 $r['error'] = [ 'code' => -32602, 'message' => 'Post not found' ];
1019 break;
1020 }
1021 $out = [
1022 'ID' => $p->ID,
1023 'post_title' => $p->post_title,
1024 'post_status' => $p->post_status,
1025 'post_content' => $this->clean_html( $p->post_content ),
1026 'post_excerpt' => $this->post_excerpt( $p ),
1027 'permalink' => get_permalink( $p ),
1028 'post_date' => $p->post_date,
1029 'post_modified' => $p->post_modified,
1030 ];
1031 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
1032 break;
1033
1034 /* ===== Posts: snapshot ===== */
1035 case 'wp_get_post_snapshot':
1036 if ( empty( $a['ID'] ) ) {
1037 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
1038 break;
1039 }
1040
1041 $post_id = intval( $a['ID'] );
1042 $p = get_post( $post_id );
1043
1044 if ( !$p ) {
1045 $r['error'] = [ 'code' => -32602, 'message' => 'Post not found' ];
1046 break;
1047 }
1048
1049 $include = $a['include'] ?? [ 'meta', 'terms', 'thumbnail', 'author' ];
1050 $exclude = $a['exclude'] ?? [];
1051
1052 // Handle JSON strings (some MCP clients send arrays as JSON strings)
1053 if ( is_string( $include ) ) {
1054 $include = json_decode( $include, true ) ?? [];
1055 }
1056 if ( is_string( $exclude ) ) {
1057 $exclude = json_decode( $exclude, true ) ?? [];
1058 }
1059
1060 $snapshot = [
1061 'post' => [
1062 'ID' => $p->ID,
1063 'post_title' => $p->post_title,
1064 'post_type' => $p->post_type,
1065 'post_status' => $p->post_status,
1066 'post_excerpt' => $this->post_excerpt( $p ),
1067 'post_name' => $p->post_name,
1068 'permalink' => get_permalink( $p ),
1069 'post_date' => $p->post_date,
1070 'post_modified' => $p->post_modified,
1071 ],
1072 ];
1073
1074 // Include content unless excluded (useful for posts with huge content)
1075 if ( !in_array( 'content', $exclude ) ) {
1076 $snapshot['post']['post_content'] = $this->clean_html( $p->post_content );
1077 }
1078
1079 // Include all post meta
1080 if ( in_array( 'meta', $include ) ) {
1081 $snapshot['meta'] = [];
1082 $all_meta = get_post_meta( $post_id );
1083 foreach ( $all_meta as $key => $value ) {
1084 if ( is_array( $value ) && count( $value ) === 1 ) {
1085 $snapshot['meta'][ $key ] = maybe_unserialize( $value[0] );
1086 }
1087 else {
1088 $snapshot['meta'][ $key ] = array_map( 'maybe_unserialize', $value );
1089 }
1090 }
1091 }
1092
1093 // Include all taxonomies and their terms
1094 if ( in_array( 'terms', $include ) ) {
1095 $snapshot['terms'] = [];
1096 $taxonomies = get_object_taxonomies( $p->post_type );
1097 foreach ( $taxonomies as $taxonomy ) {
1098 $terms = wp_get_post_terms( $post_id, $taxonomy, [ 'fields' => 'all' ] );
1099 if ( !is_wp_error( $terms ) && !empty( $terms ) ) {
1100 $snapshot['terms'][ $taxonomy ] = array_map( function ( $t ) {
1101 return [
1102 'term_id' => $t->term_id,
1103 'name' => $t->name,
1104 'slug' => $t->slug,
1105 ];
1106 }, $terms );
1107 }
1108 }
1109 }
1110
1111 // Include featured image
1112 if ( in_array( 'thumbnail', $include ) ) {
1113 $thumb_id = get_post_thumbnail_id( $post_id );
1114 if ( $thumb_id ) {
1115 $snapshot['thumbnail'] = [
1116 'ID' => $thumb_id,
1117 'url' => wp_get_attachment_url( $thumb_id ),
1118 'alt' => get_post_meta( $thumb_id, '_wp_attachment_image_alt', true ),
1119 ];
1120 }
1121 }
1122
1123 // Include author
1124 if ( in_array( 'author', $include ) ) {
1125 $author = get_userdata( $p->post_author );
1126 if ( $author ) {
1127 $snapshot['author'] = [
1128 'ID' => $author->ID,
1129 'display_name' => $author->display_name,
1130 'user_login' => $author->user_login,
1131 ];
1132 }
1133 }
1134
1135 $this->add_result_text( $r, wp_json_encode( $snapshot, JSON_PRETTY_PRINT ) );
1136 break;
1137
1138 /* ===== Posts: create ===== */
1139 case 'wp_create_post':
1140 if ( empty( $a['post_title'] ) ) {
1141 $r['error'] = [ 'code' => -32602, 'message' => 'post_title required' ];
1142 break;
1143 }
1144 $ins = [
1145 'post_title' => sanitize_text_field( $a['post_title'] ),
1146 'post_status' => sanitize_key( $a['post_status'] ?? 'draft' ),
1147 'post_type' => sanitize_key( $a['post_type'] ?? 'post' ),
1148 ];
1149 if ( $a['post_content'] ?? '' ) {
1150 $ins['post_content'] = $this->core->markdown_to_html( $a['post_content'] );
1151 }
1152 if ( $a['post_excerpt'] ?? '' ) {
1153 $ins['post_excerpt'] = $this->clean_html( $a['post_excerpt'] );
1154 }
1155 if ( $a['post_name'] ?? '' ) {
1156 $ins['post_name'] = sanitize_title( $a['post_name'] );
1157 }
1158
1159 // Handle JSON strings for meta_input (some MCP clients send objects as JSON strings)
1160 $meta_input = $a['meta_input'] ?? [];
1161 if ( is_string( $meta_input ) ) {
1162 $meta_input = json_decode( $meta_input, true ) ?? [];
1163 }
1164 if ( !empty( $meta_input ) && is_array( $meta_input ) ) {
1165 $ins['meta_input'] = $meta_input;
1166 }
1167
1168 $new = wp_insert_post( wp_slash( $ins ), true );
1169 if ( is_wp_error( $new ) ) {
1170 $r['error'] = [ 'code' => $new->get_error_code(), 'message' => $new->get_error_message() ];
1171 }
1172 else {
1173 if ( empty( $ins['meta_input'] ) && !empty( $meta_input ) && is_array( $meta_input ) ) {
1174 foreach ( $meta_input as $k => $v ) {
1175 update_post_meta( $new, sanitize_key( $k ), maybe_serialize( $v ) );
1176 }
1177 }
1178 $this->bust_post_cache( (int) $new, [ 'tool' => 'wp_create_post' ] );
1179 $this->add_result_text( $r, 'Post created ID ' . $new );
1180 }
1181 break;
1182
1183 /* ===== Posts: update ===== */
1184 case 'wp_update_post':
1185 if ( empty( $a['ID'] ) ) {
1186 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
1187 break;
1188 }
1189 $post_id = intval( $a['ID'] );
1190 $c = [ 'ID' => $post_id ];
1191
1192 // Handle JSON strings (some MCP clients send objects as JSON strings)
1193 $fields_raw = $a['fields'] ?? null;
1194 $fields = $fields_raw;
1195 if ( is_string( $fields ) ) {
1196 $fields = json_decode( $fields, true );
1197 // Detect truncated/malformed JSON
1198 if ( $fields === null && strlen( $fields_raw ) > 0 ) {
1199 $r['error'] = [ 'code' => -32602, 'message' => 'Fields parameter is invalid JSON (possibly truncated). Content may be too large for the transport. Raw length: ' . strlen( $fields_raw ) . ' bytes' ];
1200 break;
1201 }
1202 }
1203 $fields = $fields ?? [];
1204
1205 // Track what we're trying to update for verification
1206 $content_to_verify = null;
1207 if ( !empty( $fields ) && is_array( $fields ) ) {
1208 foreach ( $fields as $k => $v ) {
1209 $c[ $k ] = in_array( $k, [ 'post_content', 'post_excerpt' ], true ) ? $this->clean_html( $v ) : sanitize_text_field( $v );
1210 }
1211 if ( isset( $c['post_content'] ) ) {
1212 $content_to_verify = $c['post_content'];
1213 }
1214 }
1215
1216 // Handle schedule_for convenience parameter
1217 if ( !empty( $a['schedule_for'] ) ) {
1218 $schedule_date = sanitize_text_field( $a['schedule_for'] );
1219 $c['post_status'] = 'future';
1220 $c['post_date'] = $schedule_date;
1221 $c['post_date_gmt'] = get_gmt_from_date( $schedule_date );
1222 $c['edit_date'] = true; // Required for WordPress to respect date changes
1223 }
1224
1225 // Handle JSON strings for meta_input
1226 $meta_raw = $a['meta_input'] ?? null;
1227 $meta_input = $meta_raw;
1228 if ( is_string( $meta_input ) ) {
1229 $meta_input = json_decode( $meta_input, true );
1230 if ( $meta_input === null && strlen( $meta_raw ) > 0 ) {
1231 $r['error'] = [ 'code' => -32602, 'message' => 'meta_input parameter is invalid JSON (possibly truncated).' ];
1232 break;
1233 }
1234 }
1235 $meta_input = $meta_input ?? [];
1236 $has_meta = !empty( $meta_input ) && is_array( $meta_input );
1237 $has_fields = count( $c ) > 1;
1238
1239 // Error if nothing to update
1240 if ( !$has_fields && !$has_meta ) {
1241 $hint = '';
1242 if ( isset( $a['fields'] ) || isset( $a['meta_input'] ) ) {
1243 $hint = ' (parameters were provided but parsed as empty - check for malformed JSON)';
1244 }
1245 $r['error'] = [ 'code' => -32602, 'message' => 'No fields or meta_input provided to update' . $hint ];
1246 break;
1247 }
1248
1249 // Detect trash / untrash transitions and route through wp_trash_post() /
1250 // wp_untrash_post() so the proper hooks fire (ACF cleanup, search-index purges,
1251 // SEO plugins, etc.). A bare wp_update_post( ['post_status' => 'trash'] ) just
1252 // flips the status field and skips all of that.
1253 $u = $post_id;
1254 if ( isset( $c['post_status'] ) ) {
1255 $current = get_post( $post_id );
1256 $current_status = $current ? $current->post_status : null;
1257 $target_status = $c['post_status'];
1258
1259 if ( $target_status === 'trash' && $current_status !== 'trash' ) {
1260 $trashed = wp_trash_post( $post_id );
1261 if ( !$trashed ) {
1262 $r['error'] = [ 'code' => -32603, 'message' => 'wp_trash_post failed' ];
1263 break;
1264 }
1265 unset( $c['post_status'] );
1266 $has_fields = count( $c ) > 1;
1267 }
1268 elseif ( $current_status === 'trash' && $target_status !== 'trash' ) {
1269 $untrashed = wp_untrash_post( $post_id );
1270 if ( !$untrashed ) {
1271 $r['error'] = [ 'code' => -32603, 'message' => 'wp_untrash_post failed' ];
1272 break;
1273 }
1274 // Leave post_status in $c: wp_untrash_post restores to a previous status, and
1275 // a subsequent wp_update_post() will set the explicit one the caller asked for.
1276 }
1277 }
1278
1279 // Update post fields if any
1280 if ( $has_fields ) {
1281 $u = wp_update_post( wp_slash( $c ), true );
1282 if ( is_wp_error( $u ) ) {
1283 $r['error'] = [ 'code' => $u->get_error_code(), 'message' => $u->get_error_message() ];
1284 break;
1285 }
1286 }
1287
1288 // Update meta if any
1289 if ( $has_meta ) {
1290 foreach ( $meta_input as $k => $v ) {
1291 update_post_meta( $u, sanitize_key( $k ), maybe_serialize( $v ) );
1292 }
1293 }
1294
1295 $this->bust_post_cache( (int) $u, [ 'tool' => 'wp_update_post' ] );
1296
1297 // Verify the update actually took effect
1298 $updated_post = get_post( $u );
1299 $result = [
1300 'post_id' => $u,
1301 'post_modified' => $updated_post->post_modified,
1302 ];
1303
1304 // Verify content was saved correctly if we tried to update it
1305 if ( $content_to_verify !== null ) {
1306 $saved_content = $updated_post->post_content;
1307 $result['content_length'] = strlen( $saved_content );
1308 if ( $saved_content !== $content_to_verify ) {
1309 $result['warning'] = 'Content differs from input (sanitization applied or save failed)';
1310 $result['expected_length'] = strlen( $content_to_verify );
1311 }
1312 }
1313
1314 if ( !empty( $a['schedule_for'] ) ) {
1315 $result['scheduled_for'] = $a['schedule_for'];
1316 }
1317
1318 $this->add_result_text( $r, wp_json_encode( $result, JSON_PRETTY_PRINT ) );
1319 break;
1320
1321 /* ===== Posts: delete ===== */
1322 case 'wp_delete_post':
1323 if ( empty( $a['ID'] ) ) {
1324 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
1325 break;
1326 }
1327 $delete_id = intval( $a['ID'] );
1328 $del = wp_delete_post( $delete_id, !empty( $a['force'] ) );
1329 if ( $del ) {
1330 $this->bust_post_cache( $delete_id, [ 'tool' => 'wp_delete_post' ] );
1331 $this->add_result_text( $r, 'Post #' . $a['ID'] . ' deleted' );
1332 }
1333 else {
1334 $r['error'] = [ 'code' => -32603, 'message' => 'Deletion failed' ];
1335 }
1336 break;
1337
1338 /* ===== Posts: alter (search/replace) ===== */
1339 case 'wp_alter_post':
1340 if ( empty( $a['ID'] ) || empty( $a['field'] ) || !isset( $a['search'] ) || !isset( $a['replace'] ) ) {
1341 $r['error'] = [ 'code' => -32602, 'message' => 'ID, field, search, and replace required' ];
1342 break;
1343 }
1344 $post_id = intval( $a['ID'] );
1345 $field = sanitize_key( $a['field'] );
1346 $search = $a['search'];
1347 $replace = $a['replace'];
1348 $is_regex = !empty( $a['regex'] );
1349
1350 // Validate field
1351 $allowed_fields = [ 'post_content', 'post_excerpt', 'post_title' ];
1352 if ( !in_array( $field, $allowed_fields, true ) ) {
1353 $r['error'] = [ 'code' => -32602, 'message' => 'Field must be: post_content, post_excerpt, or post_title' ];
1354 break;
1355 }
1356
1357 $post = get_post( $post_id );
1358 if ( !$post ) {
1359 $r['error'] = [ 'code' => -32602, 'message' => 'Post not found' ];
1360 break;
1361 }
1362
1363 $content = $post->$field;
1364 $count = 0;
1365
1366 if ( $is_regex ) {
1367 // Validate regex pattern
1368 set_error_handler( fn () => false );
1369 $test = preg_match( $search, '' );
1370 restore_error_handler();
1371 if ( $test === false ) {
1372 $r['error'] = [ 'code' => -32602, 'message' => 'Invalid regex pattern' ];
1373 break;
1374 }
1375 $new_content = preg_replace( $search, $replace, $content, -1, $count );
1376 if ( $new_content === null ) {
1377 $r['error'] = [ 'code' => -32603, 'message' => 'Regex error' ];
1378 break;
1379 }
1380 }
1381 else {
1382 $new_content = str_replace( $search, $replace, $content, $count );
1383 }
1384
1385 if ( $count === 0 ) {
1386 $this->add_result_text( $r, 'No occurrences found; post unchanged.' );
1387 break;
1388 }
1389
1390 // wp_update_post() runs wp_unslash() internally, which would strip the
1391 // backslash from Unicode escapes like \u003c in block JSON (Rank Math
1392 // FAQ, etc.) and silently corrupt the post. Pre-slash to compensate.
1393 $update = wp_update_post( wp_slash( [ 'ID' => $post_id, $field => $new_content ] ), true );
1394 if ( is_wp_error( $update ) ) {
1395 $r['error'] = [ 'code' => $update->get_error_code(), 'message' => $update->get_error_message() ];
1396 break;
1397 }
1398
1399 $this->bust_post_cache( $post_id, [ 'tool' => 'wp_alter_post' ] );
1400 $this->add_result_text( $r, $count . ' replacement' . ( $count === 1 ? '' : 's' ) . ' applied to ' . $field . ' of post #' . $post_id );
1401 break;
1402
1403 /* ===== Post-meta ===== */
1404 case 'wp_get_post_meta':
1405 if ( empty( $a['ID'] ) ) {
1406 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
1407 break;
1408 }
1409 $pid = intval( $a['ID'] );
1410 $out = ( $a['key'] ?? '' ) ? get_post_meta( $pid, sanitize_key( $a['key'] ), true ) : get_post_meta( $pid );
1411 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
1412 break;
1413
1414 case 'wp_update_post_meta':
1415 if ( empty( $a['ID'] ) ) {
1416 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
1417 break;
1418 }
1419 $pid = intval( $a['ID'] );
1420
1421 // Handle JSON strings for meta (some MCP clients send objects as JSON strings)
1422 $meta = $a['meta'] ?? null;
1423 if ( is_string( $meta ) ) {
1424 $meta = json_decode( $meta, true );
1425 }
1426
1427 if ( !empty( $meta ) && is_array( $meta ) ) {
1428 foreach ( $meta as $k => $v ) {
1429 update_post_meta( $pid, sanitize_key( $k ), maybe_serialize( $v ) );
1430 }
1431 }
1432 elseif ( isset( $a['key'], $a['value'] ) ) {
1433 update_post_meta( $pid, sanitize_key( $a['key'] ), maybe_serialize( $a['value'] ) );
1434 }
1435 else {
1436 $r['error'] = [ 'code' => -32602, 'message' => 'meta array or key/value required' ];
1437 break;
1438 }
1439 $this->add_result_text( $r, 'Meta updated for post #' . $pid );
1440 break;
1441
1442 case 'wp_delete_post_meta':
1443 if ( empty( $a['ID'] ) || empty( $a['key'] ) ) {
1444 $r['error'] = [ 'code' => -32602, 'message' => 'ID & key required' ];
1445 break;
1446 }
1447 $pid = intval( $a['ID'] );
1448 $key = sanitize_key( $a['key'] );
1449 $done = isset( $a['value'] ) ? delete_post_meta( $pid, $key, maybe_serialize( $a['value'] ) ) : delete_post_meta( $pid, $key );
1450 if ( $done ) {
1451 $this->add_result_text( $r, 'Meta deleted on post #' . $pid );
1452 }
1453 else {
1454 $r['error'] = [ 'code' => -32603, 'message' => 'Deletion failed' ];
1455 }
1456 break;
1457
1458 /* ===== Featured image ===== */
1459 case 'wp_set_featured_image':
1460 if ( empty( $a['post_id'] ) ) {
1461 $r['error'] = [ 'code' => -32602, 'message' => 'post_id required' ];
1462 break;
1463 }
1464 $post_id = intval( $a['post_id'] );
1465 $media_id = isset( $a['media_id'] ) ? intval( $a['media_id'] ) : 0;
1466 if ( $media_id ) {
1467 $done = set_post_thumbnail( $post_id, $media_id );
1468 if ( $done ) {
1469 $this->add_result_text( $r, 'Featured image set on post #' . $post_id );
1470 }
1471 else {
1472 $r['error'] = [ 'code' => -32603, 'message' => 'Failed to set thumbnail' ];
1473 }
1474 }
1475 else {
1476 delete_post_thumbnail( $post_id );
1477 $this->add_result_text( $r, 'Featured image removed from post #' . $post_id );
1478 }
1479 break;
1480
1481 /* ===== Taxonomies ===== */
1482 case 'wp_get_taxonomies':
1483 $pt = sanitize_key( $a['post_type'] ?? 'post' );
1484 $out = [];
1485 foreach ( get_object_taxonomies( $pt, 'objects' ) as $t ) {
1486 $out[] = [ 'key' => $t->name, 'label' => $t->label ];
1487 }
1488 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
1489 break;
1490
1491 case 'wp_get_terms':
1492 $tax = sanitize_key( $a['taxonomy'] );
1493 $args = [
1494 'taxonomy' => $tax,
1495 'hide_empty' => false,
1496 'number' => intval( $a['limit'] ?? 0 ),
1497 'search' => $a['search'] ?? '',
1498 ];
1499 if ( isset( $a['parent'] ) ) {
1500 $args['parent'] = intval( $a['parent'] );
1501 }
1502 $out = [];
1503 foreach ( get_terms( $args ) as $t ) {
1504 $out[] = [ 'term_id' => $t->term_id, 'name' => $t->name, 'slug' => $t->slug, 'count' => $t->count ];
1505 }
1506 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
1507 break;
1508
1509 case 'wp_create_term':
1510 if ( empty( $a['term_name'] ) ) {
1511 $r['error'] = [ 'code' => -32602, 'message' => 'term_name required' ];
1512 break;
1513 }
1514 $tax = sanitize_key( $a['taxonomy'] );
1515 $args = [];
1516 if ( $a['slug'] ?? '' ) {
1517 $args['slug'] = sanitize_title( $a['slug'] );
1518 }
1519 if ( $a['description'] ?? '' ) {
1520 $args['description'] = sanitize_text_field( $a['description'] );
1521 }
1522 if ( isset( $a['parent'] ) ) {
1523 $args['parent'] = intval( $a['parent'] );
1524 }
1525 $term = wp_insert_term( sanitize_text_field( $a['term_name'] ), $tax, $args );
1526 if ( is_wp_error( $term ) ) {
1527 $r['error'] = [ 'code' => $term->get_error_code(), 'message' => $term->get_error_message() ];
1528 }
1529 else {
1530 $this->add_result_text( $r, 'Term ' . $term['term_id'] . ' created' );
1531 }
1532 break;
1533
1534 case 'wp_update_term':
1535 $tid = intval( $a['term_id'] ?? 0 );
1536 if ( !$tid ) {
1537 $r['error'] = [ 'code' => -32602, 'message' => 'term_id required' ];
1538 break;
1539 }
1540 $tax = sanitize_key( $a['taxonomy'] );
1541 $uargs = [];
1542 foreach ( [ 'name', 'slug', 'description', 'parent' ] as $f ) {
1543 if ( isset( $a[$f] ) ) {
1544 $uargs[$f] = $a[$f];
1545 }
1546 }
1547 $t = wp_update_term( $tid, $tax, $uargs );
1548 if ( is_wp_error( $t ) ) {
1549 $r['error'] = [ 'code' => $t->get_error_code(), 'message' => $t->get_error_message() ];
1550 }
1551 else {
1552 $this->add_result_text( $r, 'Term ' . $tid . ' updated' );
1553 }
1554 break;
1555
1556 case 'wp_delete_term':
1557 $tid = intval( $a['term_id'] ?? 0 );
1558 if ( !$tid ) {
1559 $r['error'] = [ 'code' => -32602, 'message' => 'term_id required' ];
1560 break;
1561 }
1562 $tax = sanitize_key( $a['taxonomy'] );
1563 $d = wp_delete_term( $tid, $tax );
1564 if ( $d ) {
1565 $this->add_result_text( $r, 'Term ' . $tid . ' deleted' );
1566 }
1567 else {
1568 $r['error'] = [ 'code' => -32603, 'message' => 'Deletion failed' ];
1569 }
1570 break;
1571
1572 case 'wp_get_post_terms':
1573 if ( empty( $a['ID'] ) ) {
1574 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
1575 break;
1576 }
1577 $tax = sanitize_key( $a['taxonomy'] ?? 'category' );
1578 $out = [];
1579 foreach ( wp_get_post_terms( intval( $a['ID'] ), $tax, [ 'fields' => 'all' ] ) as $t ) {
1580 $out[] = [ 'term_id' => $t->term_id, 'name' => $t->name ];
1581 }
1582 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
1583 break;
1584
1585 case 'wp_add_post_terms':
1586 if ( empty( $a['ID'] ) || empty( $a['terms'] ) ) {
1587 $r['error'] = [ 'code' => -32602, 'message' => 'ID & terms required' ];
1588 break;
1589 }
1590 $terms = $a['terms'];
1591 // Handle JSON strings (some MCP clients send arrays as JSON strings)
1592 if ( is_string( $terms ) ) {
1593 $terms = json_decode( $terms, true ) ?? [];
1594 }
1595 $tax = sanitize_key( $a['taxonomy'] ?? 'category' );
1596 $append = !isset( $a['append'] ) || $a['append'];
1597 $set = wp_set_post_terms( intval( $a['ID'] ), $terms, $tax, $append );
1598 if ( is_wp_error( $set ) ) {
1599 $r['error'] = [ 'code' => $set->get_error_code(), 'message' => $set->get_error_message() ];
1600 }
1601 else {
1602 $this->add_result_text( $r, 'Terms set for post #' . $a['ID'] );
1603 }
1604 break;
1605
1606 /* ===== Media: list ===== */
1607 case 'wp_get_media':
1608 $q = [
1609 'post_type' => 'attachment',
1610 's' => $a['search'] ?? '',
1611 'posts_per_page' => max( 1, intval( $a['limit'] ?? 10 ) ),
1612 'post_status' => 'inherit',
1613 ];
1614 $d = [];
1615 if ( $a['after'] ?? '' ) {
1616 $d['after'] = $a['after'];
1617 }
1618 if ( $a['before'] ?? '' ) {
1619 $d['before'] = $a['before'];
1620 }
1621 if ( $d ) {
1622 $q['date_query'] = [ $d ];
1623 }
1624 $list = [];
1625 foreach ( get_posts( $q ) as $m ) {
1626 $list[] = [ 'ID' => $m->ID, 'title' => $m->post_title, 'url' => wp_get_attachment_url( $m->ID ) ];
1627 }
1628 $this->add_result_text( $r, wp_json_encode( $list, JSON_PRETTY_PRINT ) );
1629 break;
1630
1631 /* ===== Media: upload ===== */
1632 case 'wp_upload_media':
1633 $has_url = !empty( $a['url'] );
1634 $has_base64 = !empty( $a['base64'] ) && !empty( $a['filename'] );
1635 if ( !$has_url && !$has_base64 ) {
1636 $r['error'] = [ 'code' => -32602, 'message' => 'Provide either url, or base64 + filename.' ];
1637 break;
1638 }
1639 try {
1640 require_once ABSPATH . 'wp-admin/includes/file.php';
1641 require_once ABSPATH . 'wp-admin/includes/media.php';
1642 require_once ABSPATH . 'wp-admin/includes/image.php';
1643
1644 if ( $has_url ) {
1645 $tmp = download_url( $a['url'] );
1646 if ( is_wp_error( $tmp ) ) {
1647 throw new Exception( $tmp->get_error_message(), $tmp->get_error_code() );
1648 }
1649 $file = [ 'name' => basename( parse_url( $a['url'], PHP_URL_PATH ) ), 'tmp_name' => $tmp ];
1650 }
1651 else {
1652 $decoded = base64_decode( $a['base64'], true );
1653 if ( $decoded === false ) {
1654 throw new Exception( 'Invalid base64 data.' );
1655 }
1656 $tmp = wp_tempnam( $a['filename'] );
1657 file_put_contents( $tmp, $decoded );
1658 $file = [ 'name' => sanitize_file_name( $a['filename'] ), 'tmp_name' => $tmp ];
1659 }
1660
1661 $id = media_handle_sideload( $file, 0, $a['description'] ?? '' );
1662 @unlink( $tmp );
1663 if ( is_wp_error( $id ) ) {
1664 throw new Exception( $id->get_error_message(), $id->get_error_code() );
1665 }
1666 if ( $a['title'] ?? '' ) {
1667 wp_update_post( wp_slash( [ 'ID' => $id, 'post_title' => sanitize_text_field( $a['title'] ) ] ) );
1668 }
1669 if ( $a['alt'] ?? '' ) {
1670 update_post_meta( $id, '_wp_attachment_image_alt', sanitize_text_field( $a['alt'] ) );
1671 }
1672 $this->add_result_text( $r, wp_get_attachment_url( $id ) );
1673 }
1674 catch ( \Throwable $e ) {
1675 $r['error'] = [ 'code' => $e->getCode() ?: -32603, 'message' => $e->getMessage() ];
1676 }
1677 break;
1678
1679 /* ===== Media: upload alternative (two-step) ===== */
1680 case 'wp_upload_request':
1681 if ( empty( $a['filename'] ) ) {
1682 $r['error'] = [ 'code' => -32602, 'message' => 'filename required' ];
1683 break;
1684 }
1685 try {
1686 $token = wp_generate_password( 32, false );
1687 $transient_key = 'mwai_mcp_upload_' . $token;
1688 $data = [
1689 'filename' => sanitize_file_name( $a['filename'] ),
1690 'title' => $a['title'] ?? '',
1691 'description' => $a['description'] ?? '',
1692 'alt' => $a['alt'] ?? '',
1693 ];
1694 set_transient( $transient_key, $data, 5 * MINUTE_IN_SECONDS );
1695 $upload_url = rest_url( 'mcp/v1/upload/' . $token );
1696 $this->add_result_text( $r, wp_json_encode( [
1697 'upload_url' => $upload_url,
1698 'expires_in' => '5 minutes',
1699 'usage' => 'curl -X POST -F "file=@/path/to/' . $a['filename'] . '" "' . $upload_url . '"',
1700 ], JSON_PRETTY_PRINT ) );
1701 }
1702 catch ( \Throwable $e ) {
1703 $r['error'] = [ 'code' => $e->getCode() ?: -32603, 'message' => $e->getMessage() ];
1704 }
1705 break;
1706
1707 /* ===== Media: update ===== */
1708 case 'wp_update_media':
1709 if ( empty( $a['ID'] ) ) {
1710 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
1711 break;
1712 }
1713 $upd = [ 'ID' => intval( $a['ID'] ) ];
1714 if ( $a['title'] ?? '' ) {
1715 $upd['post_title'] = sanitize_text_field( $a['title'] );
1716 }
1717 if ( $a['caption'] ?? '' ) {
1718 $upd['post_excerpt'] = $this->clean_html( $a['caption'] );
1719 }
1720 if ( $a['description'] ?? '' ) {
1721 $upd['post_content'] = $this->clean_html( $a['description'] );
1722 }
1723 $u = wp_update_post( wp_slash( $upd ), true );
1724 if ( is_wp_error( $u ) ) {
1725 $r['error'] = [ 'code' => $u->get_error_code(), 'message' => $u->get_error_message() ];
1726 }
1727 else {
1728 if ( $a['alt'] ?? '' ) {
1729 update_post_meta( $u, '_wp_attachment_image_alt', sanitize_text_field( $a['alt'] ) );
1730 }
1731 $this->add_result_text( $r, 'Media #' . $u . ' updated' );
1732 }
1733 break;
1734
1735 /* ===== Media: delete ===== */
1736 case 'wp_delete_media':
1737 if ( empty( $a['ID'] ) ) {
1738 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
1739 break;
1740 }
1741 $d = wp_delete_post( intval( $a['ID'] ), !empty( $a['force'] ) );
1742 if ( $d ) {
1743 $this->add_result_text( $r, 'Media #' . $a['ID'] . ' deleted' );
1744 }
1745 else {
1746 $r['error'] = [ 'code' => -32603, 'message' => 'Deletion failed' ];
1747 }
1748 break;
1749
1750 /* ===== MWAI Vision ===== */
1751 case 'mwai_vision':
1752 if ( empty( $a['message'] ) ) {
1753 $r['error'] = [ 'code' => -32602, 'message' => 'message required' ];
1754 break;
1755 }
1756 global $mwai;
1757 if ( !isset( $mwai ) ) {
1758 $r['error'] = [ 'code' => -32603, 'message' => 'MWAI not found' ];
1759 break;
1760 }
1761 $analysis = $mwai->simpleVisionQuery(
1762 $a['message'],
1763 $a['url'] ?? null,
1764 $a['path'] ?? null,
1765 [ 'scope' => 'mcp' ]
1766 );
1767 $this->add_result_text( $r, is_string( $analysis ) ? $analysis : wp_json_encode( $analysis, JSON_PRETTY_PRINT ) );
1768 break;
1769
1770 /* ===== MWAI Image ===== */
1771 case 'mwai_image':
1772 if ( empty( $a['message'] ) ) {
1773 $r['error'] = [ 'code' => -32602, 'message' => 'message required' ];
1774 break;
1775 }
1776 global $mwai;
1777 if ( !isset( $mwai ) ) {
1778 $r['error'] = [ 'code' => -32603, 'message' => 'MWAI not found' ];
1779 break;
1780 }
1781
1782 $media = $mwai->imageQueryForMediaLibrary( $a['message'], [ 'scope' => 'mcp' ], $a['postId'] ?? null );
1783 if ( is_wp_error( $media ) ) {
1784 $r['error'] = [ 'code' => $media->get_error_code(), 'message' => $media->get_error_message() ];
1785 break;
1786 }
1787
1788 $mid = intval( $media['id'] );
1789
1790 $upd = [ 'ID' => $mid ];
1791 if ( !empty( $a['title'] ) ) {
1792 $upd['post_title'] = sanitize_text_field( $a['title'] );
1793 }
1794 if ( !empty( $a['caption'] ) ) {
1795 $upd['post_excerpt'] = $this->clean_html( $a['caption'] );
1796 }
1797 if ( !empty( $a['description'] ) ) {
1798 $upd['post_content'] = $this->clean_html( $a['description'] );
1799 }
1800 if ( count( $upd ) > 1 ) {
1801 wp_update_post( wp_slash( $upd ), true );
1802 }
1803 if ( array_key_exists( 'alt', $a ) ) {
1804 update_post_meta( $mid, '_wp_attachment_image_alt', sanitize_text_field( (string) $a['alt'] ) );
1805 }
1806
1807 $media = [
1808 'id' => $mid,
1809 'url' => wp_get_attachment_url( $mid ),
1810 'title' => get_the_title( $mid ),
1811 'caption' => wp_get_attachment_caption( $mid ),
1812 'alt' => get_post_meta( $mid, '_wp_attachment_image_alt', true ),
1813 ];
1814 $this->add_result_text( $r, wp_json_encode( $media, JSON_PRETTY_PRINT ) );
1815 break;
1816
1817 default: $r['error'] = [ 'code' => -32601, 'message' => 'Unknown tool' ];
1818 }
1819 return $r;
1820 }
1821 #endregion
1822 }
1823