PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.3.5
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.3.5
3.7.4 3.7.3 3.7.2 3.7.1 3.7.0 3.6.9 3.6.8 3.6.7 3.6.6 3.6.4 3.6.5 3.6.3 3.6.2 3.6.1 3.6.0 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 6 months ago mcp-rest.php 1 year ago mcp.conf 1 year ago mcp.js 10 months ago mcp.md 10 months ago mcp.php 7 months ago
mcp-core.php
1709 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 #endregion
34
35 #region Tools Definitions
36 private function tools(): array {
37 return [
38
39 /* -------- Plugins -------- */
40 'wp_list_plugins' => [
41 'name' => 'wp_list_plugins',
42 'description' => 'List installed plugins (returns array of {Name, Version}).',
43 'inputSchema' => [
44 'type' => 'object',
45 'properties' => [ 'search' => [ 'type' => 'string' ] ],
46 ],
47 ],
48
49 /* -------- Users -------- */
50 'wp_get_users' => [
51 'name' => 'wp_get_users',
52 'description' => 'Retrieve users (fields: ID, user_login, display_name, roles). If no limit supplied, returns 10. `paged` ignored if `offset` is used.',
53 'inputSchema' => [
54 'type' => 'object',
55 'properties' => [
56 'search' => [ 'type' => 'string' ],
57 'role' => [ 'type' => 'string' ],
58 'limit' => [ 'type' => 'integer' ],
59 'offset' => [ 'type' => 'integer' ],
60 'paged' => [ 'type' => 'integer' ],
61 ],
62 ],
63 ],
64 'wp_create_user' => [
65 'name' => 'wp_create_user',
66 'description' => 'Create a user. Requires user_login and user_email. Optional: user_pass (random if omitted), display_name, role.',
67 'inputSchema' => [
68 'type' => 'object',
69 'properties' => [
70 'user_login' => [ 'type' => 'string' ],
71 'user_email' => [ 'type' => 'string' ],
72 'user_pass' => [ 'type' => 'string' ],
73 'display_name' => [ 'type' => 'string' ],
74 'role' => [ 'type' => 'string' ],
75 ],
76 'required' => [ 'user_login', 'user_email' ],
77 ],
78 ],
79 'wp_update_user' => [
80 'name' => 'wp_update_user',
81 'description' => 'Update a user – pass ID plus a “fields” object (user_email, display_name, user_pass, role).',
82 'inputSchema' => [
83 'type' => 'object',
84 'properties' => [
85 'ID' => [ 'type' => 'integer' ],
86 'fields' => [
87 'type' => 'object',
88 'properties' => [
89 'user_email' => [ 'type' => 'string' ],
90 'display_name' => [ 'type' => 'string' ],
91 'user_pass' => [ 'type' => 'string' ],
92 'role' => [ 'type' => 'string' ],
93 ],
94 'additionalProperties' => true
95 ],
96 ],
97 'required' => [ 'ID' ],
98 ],
99 ],
100
101 /* -------- Comments -------- */
102 'wp_get_comments' => [
103 'name' => 'wp_get_comments',
104 'description' => 'Retrieve comments (fields: comment_ID, comment_post_ID, comment_author, comment_content, comment_date, comment_approved). Returns 10 by default.',
105 'inputSchema' => [
106 'type' => 'object',
107 'properties' => [
108 'post_id' => [ 'type' => 'integer' ],
109 'status' => [ 'type' => 'string' ],
110 'search' => [ 'type' => 'string' ],
111 'limit' => [ 'type' => 'integer' ],
112 'offset' => [ 'type' => 'integer' ],
113 'paged' => [ 'type' => 'integer' ],
114 ],
115 ],
116 ],
117 'wp_create_comment' => [
118 'name' => 'wp_create_comment',
119 'description' => 'Insert a comment. Requires post_id and comment_content. Optional author, author_email, author_url.',
120 'inputSchema' => [
121 'type' => 'object',
122 'properties' => [
123 'post_id' => [ 'type' => 'integer' ],
124 'comment_content' => [ 'type' => 'string' ],
125 'comment_author' => [ 'type' => 'string' ],
126 'comment_author_email' => [ 'type' => 'string' ],
127 'comment_author_url' => [ 'type' => 'string' ],
128 'comment_approved' => [ 'type' => 'string' ],
129 ],
130 'required' => [ 'post_id', 'comment_content' ],
131 ],
132 ],
133 'wp_update_comment' => [
134 'name' => 'wp_update_comment',
135 'description' => 'Update a comment – pass comment_ID plus fields (comment_content, comment_approved).',
136 'inputSchema' => [
137 'type' => 'object',
138 'properties' => [
139 'comment_ID' => [ 'type' => 'integer' ],
140 'fields' => [
141 'type' => 'object',
142 'properties' => [
143 'comment_content' => [ 'type' => 'string' ],
144 'comment_approved' => [ 'type' => 'string' ],
145 ],
146 'additionalProperties' => true
147 ],
148 ],
149 'required' => [ 'comment_ID' ],
150 ],
151 ],
152 'wp_delete_comment' => [
153 'name' => 'wp_delete_comment',
154 'description' => 'Delete a comment. `force` true bypasses trash.',
155 'inputSchema' => [
156 'type' => 'object',
157 'properties' => [
158 'comment_ID' => [ 'type' => 'integer' ],
159 'force' => [ 'type' => 'boolean' ],
160 ],
161 'required' => [ 'comment_ID' ],
162 ],
163 ],
164
165 /* -------- Options -------- */
166 'wp_get_option' => [
167 'name' => 'wp_get_option',
168 'description' => 'Get a single WordPress option value (scalar or array) by key.',
169 'inputSchema' => [
170 'type' => 'object',
171 'properties' => [ 'key' => [ 'type' => 'string' ] ],
172 'required' => [ 'key' ],
173 ],
174 ],
175 'wp_update_option' => [
176 'name' => 'wp_update_option',
177 'description' => 'Create or update a WordPress option (JSON-serialised if necessary).',
178 'inputSchema' => [
179 'type' => 'object',
180 'properties' => [
181 'key' => [ 'type' => 'string' ],
182 'value' => [ 'type' => [ 'string', 'number', 'boolean', 'object', 'array' ] ],
183 ],
184 'required' => [ 'key', 'value' ],
185 ],
186 ],
187
188 /* -------- Counts -------- */
189 'wp_count_posts' => [
190 'name' => 'wp_count_posts',
191 'description' => 'Return counts of posts by status. Optional post_type (default post).',
192 'inputSchema' => [
193 'type' => 'object',
194 'properties' => [ 'post_type' => [ 'type' => 'string' ] ],
195 ],
196 ],
197 'wp_count_terms' => [
198 'name' => 'wp_count_terms',
199 'description' => 'Return total number of terms in a taxonomy.',
200 'inputSchema' => [
201 'type' => 'object',
202 'properties' => [ 'taxonomy' => [ 'type' => 'string' ] ],
203 'required' => [ 'taxonomy' ],
204 ],
205 ],
206 'wp_count_media' => [
207 'name' => 'wp_count_media',
208 'description' => 'Return number of attachments (optionally after/before date).',
209 'inputSchema' => [
210 'type' => 'object',
211 'properties' => [
212 'after' => [ 'type' => 'string' ],
213 'before' => [ 'type' => 'string' ],
214 ],
215 ],
216 ],
217
218 /* -------- Post-types -------- */
219 'wp_get_post_types' => [
220 'name' => 'wp_get_post_types',
221 'description' => 'List public post types (key, label).',
222 'inputSchema' => $this->empty_schema(),
223 ],
224
225 /* -------- Posts -------- */
226 'wp_get_posts' => [
227 'name' => 'wp_get_posts',
228 '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.',
229 'inputSchema' => [
230 'type' => 'object',
231 'properties' => [
232 'post_type' => [ 'type' => 'string' ],
233 'post_status' => [ 'type' => 'string' ],
234 'search' => [ 'type' => 'string' ],
235 'after' => [ 'type' => 'string' ],
236 'before' => [ 'type' => 'string' ],
237 'limit' => [ 'type' => 'integer' ],
238 'offset' => [ 'type' => 'integer' ],
239 'paged' => [ 'type' => 'integer' ],
240 ],
241 ],
242 ],
243 'wp_get_post' => [
244 'name' => 'wp_get_post',
245 'description' => 'Get basic post data by ID (title, content, status, dates). For complete data including all meta and terms, use wp_get_post_snapshot instead.',
246 'inputSchema' => [
247 'type' => 'object',
248 'properties' => [ 'ID' => [ 'type' => 'integer' ] ],
249 'required' => [ 'ID' ],
250 ],
251 ],
252 'wp_get_post_snapshot' => [
253 'name' => 'wp_get_post_snapshot',
254 '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.',
255 'inputSchema' => [
256 'type' => 'object',
257 'properties' => [
258 'ID' => [ 'type' => 'integer', 'description' => 'Post ID' ],
259 'include' => [
260 'type' => 'array',
261 'description' => 'Optional: fields to include (default: all). Options: meta, terms, thumbnail, author',
262 'items' => [ 'type' => 'string' ],
263 ],
264 'exclude' => [
265 'type' => 'array',
266 'description' => 'Optional: fields to exclude from post data. Options: content (useful for posts with huge content like many galleries)',
267 'items' => [ 'type' => 'string' ],
268 ],
269 ],
270 'required' => [ 'ID' ],
271 ],
272 ],
273 'wp_create_post' => [
274 'name' => 'wp_create_post',
275 'description' => 'Create a post or page – post_title required; Markdown accepted in post_content; defaults to draft post_status and post post_type; set categories later with wp_add_post_terms; meta_input is an associative array of custom-field key/value pairs.',
276 'inputSchema' => [
277 'type' => 'object',
278 'properties' => [
279 'post_title' => [ 'type' => 'string' ],
280 'post_content' => [ 'type' => 'string' ],
281 'post_excerpt' => [ 'type' => 'string' ],
282 'post_status' => [ 'type' => 'string' ],
283 'post_type' => [ 'type' => 'string' ],
284 'post_name' => [ 'type' => 'string' ],
285 'meta_input' => [ 'type' => 'object', 'description' => 'Associative array of custom fields.' ],
286 ],
287 'required' => [ 'post_title' ],
288 ],
289 ],
290 'wp_update_post' => [
291 'name' => 'wp_update_post',
292 '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.',
293 'inputSchema' => [
294 'type' => 'object',
295 'properties' => [
296 'ID' => [ 'type' => 'integer', 'description' => 'The ID of the post to update.' ],
297 'fields' => [
298 'type' => 'object',
299 'properties' => [
300 'post_title' => [ 'type' => 'string' ],
301 'post_content' => [ 'type' => 'string' ],
302 'post_status' => [ 'type' => 'string' ],
303 'post_name' => [ 'type' => 'string' ],
304 'post_excerpt' => [ 'type' => 'string' ],
305 'post_category' => [ 'type' => 'array', 'items' => [ 'type' => 'integer' ] ],
306 ],
307 'additionalProperties' => true
308 ],
309 'meta_input' => [
310 'type' => 'object',
311 'description' => 'Associative array of custom fields.'
312 ],
313 'schedule_for' => [
314 'type' => 'string',
315 '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.'
316 ],
317 ],
318 'required' => [ 'ID' ],
319 ],
320 ],
321 'wp_delete_post' => [
322 'name' => 'wp_delete_post',
323 'description' => 'Delete/trash a post.',
324 'inputSchema' => [
325 'type' => 'object',
326 'properties' => [
327 'ID' => [ 'type' => 'integer' ],
328 'force' => [ 'type' => 'boolean' ],
329 ],
330 'required' => [ 'ID' ],
331 ],
332 ],
333 'wp_alter_post' => [
334 'name' => 'wp_alter_post',
335 '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).',
336 'inputSchema' => [
337 'type' => 'object',
338 'properties' => [
339 'ID' => [ 'type' => 'integer', 'description' => 'Post ID.' ],
340 'field' => [ 'type' => 'string', 'description' => 'Field to modify: post_content, post_excerpt, or post_title.' ],
341 'search' => [ 'type' => 'string', 'description' => 'Text or regex pattern to search for.' ],
342 'replace' => [ 'type' => 'string', 'description' => 'Replacement text.' ],
343 'regex' => [ 'type' => 'boolean', 'description' => 'Treat search as regex pattern (default: false).' ],
344 ],
345 'required' => [ 'ID', 'field', 'search', 'replace' ],
346 ],
347 ],
348
349 /* -------- Post-meta -------- */
350 'wp_get_post_meta' => [
351 'name' => 'wp_get_post_meta',
352 '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.',
353 'inputSchema' => [
354 'type' => 'object',
355 'properties' => [
356 'ID' => [ 'type' => 'integer' ],
357 'key' => [ 'type' => 'string' ],
358 ],
359 'required' => [ 'ID' ],
360 ],
361 ],
362 'wp_update_post_meta' => [
363 'name' => 'wp_update_post_meta',
364 '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.',
365 'inputSchema' => [
366 'type' => 'object',
367 'properties' => [
368 'ID' => [ 'type' => 'integer' ],
369 'meta' => [ 'type' => 'object', 'description' => 'Key/value pairs to set. Alternative: provide "key" + "value".' ],
370 'key' => [ 'type' => 'string' ],
371 'value' => [ 'type' => [ 'string', 'number', 'boolean' ] ],
372 ],
373 'required' => [ 'ID' ],
374 ],
375 ],
376 'wp_delete_post_meta' => [
377 'name' => 'wp_delete_post_meta',
378 'description' => 'Delete custom field(s) from a post. Provide value to remove a single row; omit value to delete all rows for the key.',
379 'inputSchema' => [
380 'type' => 'object',
381 'properties' => [
382 'ID' => [ 'type' => 'integer' ],
383 'key' => [ 'type' => 'string' ],
384 'value' => [ 'type' => [ 'string', 'number', 'boolean' ] ],
385 ],
386 'required' => [ 'ID', 'key' ],
387 ],
388 ],
389
390 /* -------- Featured image -------- */
391 'wp_set_featured_image' => [
392 'name' => 'wp_set_featured_image',
393 'description' => 'Attach or remove a featured image (thumbnail) for a post/page. Provide media_id to attach, omit or null to remove.',
394 'inputSchema' => [
395 'type' => 'object',
396 'properties' => [
397 'post_id' => [ 'type' => 'integer' ],
398 'media_id' => [ 'type' => 'integer' ],
399 ],
400 'required' => [ 'post_id' ],
401 ],
402 ],
403
404 /* -------- Taxonomies / Terms -------- */
405 'wp_get_taxonomies' => [
406 'name' => 'wp_get_taxonomies',
407 'description' => 'List taxonomies for a post type.',
408 'inputSchema' => [
409 'type' => 'object',
410 'properties' => [ 'post_type' => [ 'type' => 'string' ] ],
411 ],
412 ],
413 'wp_get_terms' => [
414 'name' => 'wp_get_terms',
415 'description' => 'List terms of a taxonomy.',
416 'inputSchema' => [
417 'type' => 'object',
418 'properties' => [
419 'taxonomy' => [ 'type' => 'string' ],
420 'search' => [ 'type' => 'string' ],
421 'parent' => [ 'type' => 'integer' ],
422 'limit' => [ 'type' => 'integer' ],
423 ],
424 'required' => [ 'taxonomy' ],
425 ],
426 ],
427 'wp_create_term' => [
428 'name' => 'wp_create_term',
429 'description' => 'Create a term.',
430 'inputSchema' => [
431 'type' => 'object',
432 'properties' => [
433 'taxonomy' => [ 'type' => 'string' ],
434 'term_name' => [ 'type' => 'string' ],
435 'slug' => [ 'type' => 'string' ],
436 'description' => [ 'type' => 'string' ],
437 'parent' => [ 'type' => 'integer' ],
438 ],
439 'required' => [ 'taxonomy', 'term_name' ],
440 ],
441 ],
442 'wp_update_term' => [
443 'name' => 'wp_update_term',
444 'description' => 'Update a term.',
445 'inputSchema' => [
446 'type' => 'object',
447 'properties' => [
448 'term_id' => [ 'type' => 'integer' ],
449 'taxonomy' => [ 'type' => 'string' ],
450 'name' => [ 'type' => 'string' ],
451 'slug' => [ 'type' => 'string' ],
452 'description' => [ 'type' => 'string' ],
453 'parent' => [ 'type' => 'integer' ],
454 ],
455 'required' => [ 'term_id', 'taxonomy' ],
456 ],
457 ],
458 'wp_delete_term' => [
459 'name' => 'wp_delete_term',
460 'description' => 'Delete a term.',
461 'inputSchema' => [
462 'type' => 'object',
463 'properties' => [
464 'term_id' => [ 'type' => 'integer' ],
465 'taxonomy' => [ 'type' => 'string' ],
466 ],
467 'required' => [ 'term_id', 'taxonomy' ],
468 ],
469 ],
470 'wp_get_post_terms' => [
471 'name' => 'wp_get_post_terms',
472 'description' => 'Get terms attached to a post.',
473 'inputSchema' => [
474 'type' => 'object',
475 'properties' => [
476 'ID' => [ 'type' => 'integer' ],
477 'taxonomy' => [ 'type' => 'string' ],
478 ],
479 'required' => [ 'ID' ],
480 ],
481 ],
482 'wp_add_post_terms' => [
483 'name' => 'wp_add_post_terms',
484 '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.).',
485 'inputSchema' => [
486 'type' => 'object',
487 'properties' => [
488 'ID' => [ 'type' => 'integer' ],
489 'taxonomy' => [ 'type' => 'string' ],
490 'terms' => [ 'type' => 'array', 'items' => [ 'type' => 'integer' ] ],
491 'append' => [ 'type' => 'boolean' ],
492 ],
493 'required' => [ 'ID', 'terms' ],
494 ],
495 ],
496
497 /* -------- Media -------- */
498 'wp_get_media' => [
499 'name' => 'wp_get_media',
500 'description' => 'List media items.',
501 'inputSchema' => [
502 'type' => 'object',
503 'properties' => [
504 'search' => [ 'type' => 'string' ],
505 'after' => [ 'type' => 'string' ],
506 'before' => [ 'type' => 'string' ],
507 'limit' => [ 'type' => 'integer' ],
508 ],
509 ],
510 ],
511 'wp_upload_media' => [
512 'name' => 'wp_upload_media',
513 '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.',
514 'inputSchema' => [
515 'type' => 'object',
516 'properties' => [
517 'url' => [
518 'type' => 'string',
519 'description' => 'URL to download the file from. Use this OR base64/filename.',
520 ],
521 'base64' => [
522 'type' => 'string',
523 'description' => 'Base64-encoded file content. Must be used together with filename.',
524 ],
525 'filename' => [
526 'type' => 'string',
527 'description' => 'Filename with extension (e.g. photo.jpg). Required when using base64.',
528 ],
529 'title' => [ 'type' => 'string' ],
530 'description' => [ 'type' => 'string' ],
531 'alt' => [ 'type' => 'string' ],
532 ],
533 ],
534 ],
535 'wp_upload_request' => [
536 'name' => 'wp_upload_request',
537 '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.',
538 'inputSchema' => [
539 'type' => 'object',
540 'properties' => [
541 'filename' => [
542 'type' => 'string',
543 'description' => 'Filename with extension (e.g. photo.jpg).',
544 ],
545 'title' => [ 'type' => 'string' ],
546 'description' => [ 'type' => 'string' ],
547 'alt' => [ 'type' => 'string' ],
548 ],
549 'required' => [ 'filename' ],
550 ],
551 ],
552 'wp_update_media' => [
553 'name' => 'wp_update_media',
554 'description' => 'Update attachment meta.',
555 'inputSchema' => [
556 'type' => 'object',
557 'properties' => [
558 'ID' => [ 'type' => 'integer' ],
559 'title' => [ 'type' => 'string' ],
560 'caption' => [ 'type' => 'string' ],
561 'description' => [ 'type' => 'string' ],
562 'alt' => [ 'type' => 'string' ],
563 ],
564 'required' => [ 'ID' ],
565 ],
566 ],
567 'wp_delete_media' => [
568 'name' => 'wp_delete_media',
569 'description' => 'Delete/trash an attachment.',
570 'inputSchema' => [
571 'type' => 'object',
572 'properties' => [
573 'ID' => [ 'type' => 'integer' ],
574 'force' => [ 'type' => 'boolean' ],
575 ],
576 'required' => [ 'ID' ],
577 ],
578 ],
579
580 /* -------- MWAI Vision / Image -------- */
581 'mwai_vision' => [
582 'name' => 'mwai_vision',
583 'description' => 'Analyze an image via AI Engine Vision.',
584 'inputSchema' => [
585 'type' => 'object',
586 'properties' => [
587 'message' => [ 'type' => 'string' ],
588 'url' => [ 'type' => 'string' ],
589 'path' => [ 'type' => 'string' ],
590 ],
591 'required' => [ 'message' ],
592 ],
593 ],
594 'mwai_image' => [
595 'name' => 'mwai_image',
596 '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 }.',
597 'inputSchema' => [
598 'type' => 'object',
599 'properties' => [
600 'message' => [ 'type' => 'string', 'description' => 'Prompt describing the desired image.' ],
601 'postId' => [ 'type' => 'integer', 'description' => 'Optional post ID to attach the image to.' ],
602 'title' => [ 'type' => 'string' ],
603 'caption' => [ 'type' => 'string' ],
604 'description' => [ 'type' => 'string' ],
605 'alt' => [ 'type' => 'string' ],
606 ],
607 'required' => [ 'message' ],
608 ],
609 ],
610
611 ];
612 }
613 #endregion
614
615 #region Tool Registration
616 public function register_rest_tools( array $prev ): array {
617 $tools = $this->tools();
618
619 // All 36 core tools enabled and tested with ChatGPT.
620 // Automatic validation in mcp.php fixes problematic type definitions.
621
622 // Add category and annotations to each tool
623 foreach ( $tools as &$tool ) {
624 if ( !isset( $tool['category'] ) ) {
625 $tool['category'] = 'AI Engine (Core)';
626 }
627
628 // Add MCP tool annotations based on tool name/behavior
629 if ( !isset( $tool['annotations'] ) ) {
630 $name = $tool['name'];
631
632 // Read-only tools (safe, no modifications)
633 $is_readonly = (
634 strpos( $name, 'wp_get_' ) === 0 ||
635 strpos( $name, 'wp_list_' ) === 0 ||
636 strpos( $name, 'wp_count_' ) === 0 ||
637 $name === 'mwai_vision'
638 );
639
640 // Destructive tools (can delete/destroy data)
641 $is_destructive = (
642 strpos( $name, 'wp_delete_' ) === 0 ||
643 $name === 'wp_update_user' // Can change passwords/roles
644 );
645
646 $tool['annotations'] = [
647 'readOnlyHint' => $is_readonly,
648 'destructiveHint' => !$is_readonly && $is_destructive,
649 'openWorldHint' => false, // All operate on closed WordPress system
650 ];
651 }
652 }
653
654 $merged = array_merge( $prev, array_values( $tools ) );
655 return $merged;
656 }
657 #endregion
658
659 #region Callback
660 public function handle_call( $prev, string $tool, array $args, ?int $id ) {
661 // Security check is already done in the MCP auth layer
662 // If we reach here, the user is authorized to use MCP
663 if ( !empty( $prev ) || !isset( $this->tools()[ $tool ] ) ) {
664 return $prev;
665 }
666 return $this->dispatch( $tool, $args, $id );
667 }
668 #endregion
669
670 #region Dispatcher
671 private function dispatch( string $tool, array $a, ?int $id ): array {
672 $r = [ 'jsonrpc' => '2.0', 'id' => $id ];
673
674 switch ( $tool ) {
675
676 /* ===== Users ===== */
677 case 'wp_get_users':
678 $q = [
679 'search' => '*' . esc_attr( $a['search'] ?? '' ) . '*',
680 'role' => $a['role'] ?? '',
681 'number' => max( 1, intval( $a['limit'] ?? 10 ) ),
682 ];
683 if ( isset( $a['offset'] ) ) {
684 $q['offset'] = max( 0, intval( $a['offset'] ) );
685 }
686 if ( isset( $a['paged'] ) ) {
687 $q['paged'] = max( 1, intval( $a['paged'] ) );
688 }
689 $rows = [];
690 foreach ( get_users( $q ) as $u ) {
691 $rows[] = [
692 'ID' => $u->ID,
693 'user_login' => $u->user_login,
694 'display_name' => $u->display_name,
695 'roles' => $u->roles,
696 ];
697 }
698 $this->add_result_text( $r, wp_json_encode( $rows, JSON_PRETTY_PRINT ) );
699 break;
700
701 case 'wp_create_user':
702 $data = [
703 'user_login' => sanitize_user( $a['user_login'] ),
704 'user_email' => sanitize_email( $a['user_email'] ),
705 'user_pass' => $a['user_pass'] ?? wp_generate_password( 12, true ),
706 'display_name' => sanitize_text_field( $a['display_name'] ?? '' ),
707 'role' => sanitize_key( $a['role'] ?? get_option( 'default_role', 'subscriber' ) ),
708 ];
709 $uid = wp_insert_user( $data );
710 if ( is_wp_error( $uid ) ) {
711 $r['error'] = [ 'code' => $uid->get_error_code(), 'message' => $uid->get_error_message() ];
712 }
713 else {
714 $this->add_result_text( $r, 'User created ID ' . $uid );
715 }
716 break;
717
718 case 'wp_update_user':
719 if ( empty( $a['ID'] ) ) {
720 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
721 break;
722 }
723 $upd = [ 'ID' => intval( $a['ID'] ) ];
724 if ( !empty( $a['fields'] ) && is_array( $a['fields'] ) ) {
725 foreach ( $a['fields'] as $k => $v ) {
726 $upd[ $k ] = ( $k === 'role' ) ? sanitize_key( $v ) : sanitize_text_field( $v );
727 }
728 }
729 $u = wp_update_user( $upd );
730 if ( is_wp_error( $u ) ) {
731 $r['error'] = [ 'code' => $u->get_error_code(), 'message' => $u->get_error_message() ];
732 }
733 else {
734 $this->add_result_text( $r, 'User #' . $u . ' updated' );
735 }
736 break;
737
738 /* ===== Comments ===== */
739 case 'wp_get_comments':
740 $args = [
741 'post_id' => isset( $a['post_id'] ) ? intval( $a['post_id'] ) : '',
742 'status' => $a['status'] ?? 'approve',
743 'search' => $a['search'] ?? '',
744 'number' => max( 1, intval( $a['limit'] ?? 10 ) ),
745 ];
746 if ( isset( $a['offset'] ) ) {
747 $args['offset'] = max( 0, intval( $a['offset'] ) );
748 }
749 if ( isset( $a['paged'] ) ) {
750 $args['paged'] = max( 1, intval( $a['paged'] ) );
751 }
752 $list = [];
753 foreach ( get_comments( $args ) as $c ) {
754 $list[] = [
755 'comment_ID' => $c->comment_ID,
756 'comment_post_ID' => $c->comment_post_ID,
757 'comment_author' => $c->comment_author,
758 'comment_content' => wp_trim_words( wp_strip_all_tags( $c->comment_content ), 40 ),
759 'comment_date' => $c->comment_date,
760 'comment_approved' => $c->comment_approved,
761 ];
762 }
763 $this->add_result_text( $r, wp_json_encode( $list, JSON_PRETTY_PRINT ) );
764 break;
765
766 case 'wp_create_comment':
767 if ( empty( $a['post_id'] ) || empty( $a['comment_content'] ) ) {
768 $r['error'] = [ 'code' => -32602, 'message' => 'post_id & comment_content required' ];
769 break;
770 }
771 $ins = [
772 'comment_post_ID' => intval( $a['post_id'] ),
773 'comment_content' => $this->clean_html( $a['comment_content'] ),
774 'comment_author' => sanitize_text_field( $a['comment_author'] ?? '' ),
775 'comment_author_email' => sanitize_email( $a['comment_author_email'] ?? '' ),
776 'comment_author_url' => esc_url_raw( $a['comment_author_url'] ?? '' ),
777 'comment_approved' => $a['comment_approved'] ?? 1,
778 ];
779 $cid = wp_insert_comment( $ins );
780 if ( is_wp_error( $cid ) ) {
781 /** @var WP_Error $cid */
782 $r['error'] = [ 'code' => $cid->get_error_code(), 'message' => $cid->get_error_message() ];
783 }
784 else {
785 $this->add_result_text( $r, 'Comment created ID ' . $cid );
786 }
787 break;
788
789 case 'wp_update_comment':
790 if ( empty( $a['comment_ID'] ) ) {
791 $r['error'] = [ 'code' => -32602, 'message' => 'comment_ID required' ];
792 break;
793 }
794 $c = [ 'comment_ID' => intval( $a['comment_ID'] ) ];
795 if ( !empty( $a['fields'] ) && is_array( $a['fields'] ) ) {
796 foreach ( $a['fields'] as $k => $v ) {
797 $c[ $k ] = ( $k === 'comment_content' ) ? $this->clean_html( $v ) : sanitize_text_field( $v );
798 }
799 }
800 $cid = wp_update_comment( $c, true );
801 if ( is_wp_error( $cid ) ) {
802 $r['error'] = [ 'code' => $cid->get_error_code(), 'message' => $cid->get_error_message() ];
803 }
804 else {
805 $this->add_result_text( $r, 'Comment #' . $cid . ' updated' );
806 }
807 break;
808
809 case 'wp_delete_comment':
810 if ( empty( $a['comment_ID'] ) ) {
811 $r['error'] = [ 'code' => -32602, 'message' => 'comment_ID required' ];
812 break;
813 }
814 $done = wp_delete_comment( intval( $a['comment_ID'] ), !empty( $a['force'] ) );
815 if ( $done ) {
816 $this->add_result_text( $r, 'Comment #' . $a['comment_ID'] . ' deleted' );
817 }
818 else {
819 $r['error'] = [ 'code' => -32603, 'message' => 'Deletion failed' ];
820 }
821 break;
822
823 /* ===== Options ===== */
824 case 'wp_get_option':
825 $val = get_option( sanitize_key( $a['key'] ) );
826 $this->add_result_text( $r, wp_json_encode( $val, JSON_PRETTY_PRINT ) );
827 break;
828
829 case 'wp_update_option':
830 $set = update_option( sanitize_key( $a['key'] ), $a['value'], 'yes' );
831 if ( $set ) {
832 $this->add_result_text( $r, 'Option "' . $a['key'] . '" updated' );
833 }
834 else {
835 $r['error'] = [ 'code' => -32603, 'message' => 'Update failed' ];
836 }
837 break;
838
839 /* ===== Counts ===== */
840 case 'wp_count_posts':
841 $pt = sanitize_key( $a['post_type'] ?? 'post' );
842 $obj = wp_count_posts( $pt );
843 $this->add_result_text( $r, wp_json_encode( $obj, JSON_PRETTY_PRINT ) );
844 break;
845
846 case 'wp_count_terms':
847 $tax = sanitize_key( $a['taxonomy'] );
848 $total = wp_count_terms( $tax, [ 'hide_empty' => false ] );
849 if ( is_wp_error( $total ) ) {
850 $r['error'] = [ 'code' => $total->get_error_code(), 'message' => $total->get_error_message() ];
851 }
852 else {
853 $this->add_result_text( $r, (string) $total );
854 }
855 break;
856
857 case 'wp_count_media':
858 $args = [ 'post_type' => 'attachment', 'post_status' => 'inherit', 'fields' => 'ids' ];
859 $d = [];
860 if ( $a['after'] ?? '' ) {
861 $d['after'] = $a['after'];
862 }
863 if ( $a['before'] ?? '' ) {
864 $d['before'] = $a['before'];
865 }
866 if ( $d ) {
867 $args['date_query'] = [ $d ];
868 }
869 $total = count( get_posts( $args ) );
870 $this->add_result_text( $r, (string) $total );
871 break;
872
873 /* ===== Post-types ===== */
874 case 'wp_get_post_types':
875 $out = [];
876 foreach ( get_post_types( [ 'public' => true ], 'objects' ) as $pt ) {
877 $out[] = [ 'key' => $pt->name, 'label' => $pt->label ];
878 }
879 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
880 break;
881
882 /* ===== Plugins ===== */
883 case 'wp_list_plugins':
884 if ( !function_exists( 'get_plugins' ) ) {
885 require_once ABSPATH . 'wp-admin/includes/plugin.php';
886 }
887 $search = sanitize_text_field( $a['search'] ?? '' );
888 $out = [];
889 foreach ( get_plugins() as $p ) {
890 if ( !$search || stripos( $p['Name'], $search ) !== false ) {
891 $out[] = [ 'Name' => $p['Name'], 'Version' => $p['Version'] ];
892 }
893 }
894 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
895 break;
896
897 /* ===== Posts: list ===== */
898 case 'wp_get_posts':
899 $q = [
900 'post_type' => sanitize_key( $a['post_type'] ?? 'post' ),
901 'post_status' => sanitize_key( $a['post_status'] ?? 'publish' ),
902 's' => sanitize_text_field( $a['search'] ?? '' ),
903 'posts_per_page' => max( 1, intval( $a['limit'] ?? 10 ) ),
904 ];
905 if ( isset( $a['offset'] ) ) {
906 $q['offset'] = max( 0, intval( $a['offset'] ) );
907 }
908 if ( isset( $a['paged'] ) ) {
909 $q['paged'] = max( 1, intval( $a['paged'] ) );
910 }
911 $date = [];
912 if ( $a['after'] ?? '' ) {
913 $date['after'] = $a['after'];
914 }
915 if ( $a['before'] ?? '' ) {
916 $date['before'] = $a['before'];
917 }
918 if ( $date ) {
919 $q['date_query'] = [ $date ];
920 }
921 $rows = [];
922 foreach ( get_posts( $q ) as $p ) {
923 $rows[] = [
924 'ID' => $p->ID,
925 'post_title' => $p->post_title,
926 'post_status' => $p->post_status,
927 'post_excerpt' => $this->post_excerpt( $p ),
928 'permalink' => get_permalink( $p ),
929 ];
930 }
931 $this->add_result_text( $r, wp_json_encode( $rows, JSON_PRETTY_PRINT ) );
932 break;
933
934 /* ===== Posts: single ===== */
935 case 'wp_get_post':
936 if ( empty( $a['ID'] ) ) {
937 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
938 break;
939 }
940 $p = get_post( intval( $a['ID'] ) );
941 if ( !$p ) {
942 $r['error'] = [ 'code' => -32602, 'message' => 'Post not found' ];
943 break;
944 }
945 $out = [
946 'ID' => $p->ID,
947 'post_title' => $p->post_title,
948 'post_status' => $p->post_status,
949 'post_content' => $this->clean_html( $p->post_content ),
950 'post_excerpt' => $this->post_excerpt( $p ),
951 'permalink' => get_permalink( $p ),
952 'post_date' => $p->post_date,
953 'post_modified' => $p->post_modified,
954 ];
955 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
956 break;
957
958 /* ===== Posts: snapshot ===== */
959 case 'wp_get_post_snapshot':
960 if ( empty( $a['ID'] ) ) {
961 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
962 break;
963 }
964
965 $post_id = intval( $a['ID'] );
966 $p = get_post( $post_id );
967
968 if ( !$p ) {
969 $r['error'] = [ 'code' => -32602, 'message' => 'Post not found' ];
970 break;
971 }
972
973 $include = $a['include'] ?? [ 'meta', 'terms', 'thumbnail', 'author' ];
974 $exclude = $a['exclude'] ?? [];
975
976 // Handle JSON strings (some MCP clients send arrays as JSON strings)
977 if ( is_string( $include ) ) {
978 $include = json_decode( $include, true ) ?? [];
979 }
980 if ( is_string( $exclude ) ) {
981 $exclude = json_decode( $exclude, true ) ?? [];
982 }
983
984 $snapshot = [
985 'post' => [
986 'ID' => $p->ID,
987 'post_title' => $p->post_title,
988 'post_type' => $p->post_type,
989 'post_status' => $p->post_status,
990 'post_excerpt' => $this->post_excerpt( $p ),
991 'post_name' => $p->post_name,
992 'permalink' => get_permalink( $p ),
993 'post_date' => $p->post_date,
994 'post_modified' => $p->post_modified,
995 ],
996 ];
997
998 // Include content unless excluded (useful for posts with huge content)
999 if ( !in_array( 'content', $exclude ) ) {
1000 $snapshot['post']['post_content'] = $this->clean_html( $p->post_content );
1001 }
1002
1003 // Include all post meta
1004 if ( in_array( 'meta', $include ) ) {
1005 $snapshot['meta'] = [];
1006 $all_meta = get_post_meta( $post_id );
1007 foreach ( $all_meta as $key => $value ) {
1008 if ( is_array( $value ) && count( $value ) === 1 ) {
1009 $snapshot['meta'][ $key ] = maybe_unserialize( $value[0] );
1010 }
1011 else {
1012 $snapshot['meta'][ $key ] = array_map( 'maybe_unserialize', $value );
1013 }
1014 }
1015 }
1016
1017 // Include all taxonomies and their terms
1018 if ( in_array( 'terms', $include ) ) {
1019 $snapshot['terms'] = [];
1020 $taxonomies = get_object_taxonomies( $p->post_type );
1021 foreach ( $taxonomies as $taxonomy ) {
1022 $terms = wp_get_post_terms( $post_id, $taxonomy, [ 'fields' => 'all' ] );
1023 if ( !is_wp_error( $terms ) && !empty( $terms ) ) {
1024 $snapshot['terms'][ $taxonomy ] = array_map( function ( $t ) {
1025 return [
1026 'term_id' => $t->term_id,
1027 'name' => $t->name,
1028 'slug' => $t->slug,
1029 ];
1030 }, $terms );
1031 }
1032 }
1033 }
1034
1035 // Include featured image
1036 if ( in_array( 'thumbnail', $include ) ) {
1037 $thumb_id = get_post_thumbnail_id( $post_id );
1038 if ( $thumb_id ) {
1039 $snapshot['thumbnail'] = [
1040 'ID' => $thumb_id,
1041 'url' => wp_get_attachment_url( $thumb_id ),
1042 'alt' => get_post_meta( $thumb_id, '_wp_attachment_image_alt', true ),
1043 ];
1044 }
1045 }
1046
1047 // Include author
1048 if ( in_array( 'author', $include ) ) {
1049 $author = get_userdata( $p->post_author );
1050 if ( $author ) {
1051 $snapshot['author'] = [
1052 'ID' => $author->ID,
1053 'display_name' => $author->display_name,
1054 'user_login' => $author->user_login,
1055 ];
1056 }
1057 }
1058
1059 $this->add_result_text( $r, wp_json_encode( $snapshot, JSON_PRETTY_PRINT ) );
1060 break;
1061
1062 /* ===== Posts: create ===== */
1063 case 'wp_create_post':
1064 if ( empty( $a['post_title'] ) ) {
1065 $r['error'] = [ 'code' => -32602, 'message' => 'post_title required' ];
1066 break;
1067 }
1068 $ins = [
1069 'post_title' => sanitize_text_field( $a['post_title'] ),
1070 'post_status' => sanitize_key( $a['post_status'] ?? 'draft' ),
1071 'post_type' => sanitize_key( $a['post_type'] ?? 'post' ),
1072 ];
1073 if ( $a['post_content'] ?? '' ) {
1074 $ins['post_content'] = $this->core->markdown_to_html( $a['post_content'] );
1075 }
1076 if ( $a['post_excerpt'] ?? '' ) {
1077 $ins['post_excerpt'] = $this->clean_html( $a['post_excerpt'] );
1078 }
1079 if ( $a['post_name'] ?? '' ) {
1080 $ins['post_name'] = sanitize_title( $a['post_name'] );
1081 }
1082
1083 // Handle JSON strings for meta_input (some MCP clients send objects as JSON strings)
1084 $meta_input = $a['meta_input'] ?? [];
1085 if ( is_string( $meta_input ) ) {
1086 $meta_input = json_decode( $meta_input, true ) ?? [];
1087 }
1088 if ( !empty( $meta_input ) && is_array( $meta_input ) ) {
1089 $ins['meta_input'] = $meta_input;
1090 }
1091
1092 $new = wp_insert_post( $ins, true );
1093 if ( is_wp_error( $new ) ) {
1094 $r['error'] = [ 'code' => $new->get_error_code(), 'message' => $new->get_error_message() ];
1095 }
1096 else {
1097 if ( empty( $ins['meta_input'] ) && !empty( $meta_input ) && is_array( $meta_input ) ) {
1098 foreach ( $meta_input as $k => $v ) {
1099 update_post_meta( $new, sanitize_key( $k ), maybe_serialize( $v ) );
1100 }
1101 }
1102 $this->add_result_text( $r, 'Post created ID ' . $new );
1103 }
1104 break;
1105
1106 /* ===== Posts: update ===== */
1107 case 'wp_update_post':
1108 if ( empty( $a['ID'] ) ) {
1109 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
1110 break;
1111 }
1112 $post_id = intval( $a['ID'] );
1113 $c = [ 'ID' => $post_id ];
1114
1115 // Handle JSON strings (some MCP clients send objects as JSON strings)
1116 $fields_raw = $a['fields'] ?? null;
1117 $fields = $fields_raw;
1118 if ( is_string( $fields ) ) {
1119 $fields = json_decode( $fields, true );
1120 // Detect truncated/malformed JSON
1121 if ( $fields === null && strlen( $fields_raw ) > 0 ) {
1122 $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' ];
1123 break;
1124 }
1125 }
1126 $fields = $fields ?? [];
1127
1128 // Track what we're trying to update for verification
1129 $content_to_verify = null;
1130 if ( !empty( $fields ) && is_array( $fields ) ) {
1131 foreach ( $fields as $k => $v ) {
1132 $c[ $k ] = in_array( $k, [ 'post_content', 'post_excerpt' ], true ) ? $this->clean_html( $v ) : sanitize_text_field( $v );
1133 }
1134 if ( isset( $c['post_content'] ) ) {
1135 $content_to_verify = $c['post_content'];
1136 }
1137 }
1138
1139 // Handle schedule_for convenience parameter
1140 if ( !empty( $a['schedule_for'] ) ) {
1141 $schedule_date = sanitize_text_field( $a['schedule_for'] );
1142 $c['post_status'] = 'future';
1143 $c['post_date'] = $schedule_date;
1144 $c['post_date_gmt'] = get_gmt_from_date( $schedule_date );
1145 $c['edit_date'] = true; // Required for WordPress to respect date changes
1146 }
1147
1148 // Handle JSON strings for meta_input
1149 $meta_raw = $a['meta_input'] ?? null;
1150 $meta_input = $meta_raw;
1151 if ( is_string( $meta_input ) ) {
1152 $meta_input = json_decode( $meta_input, true );
1153 if ( $meta_input === null && strlen( $meta_raw ) > 0 ) {
1154 $r['error'] = [ 'code' => -32602, 'message' => 'meta_input parameter is invalid JSON (possibly truncated).' ];
1155 break;
1156 }
1157 }
1158 $meta_input = $meta_input ?? [];
1159 $has_meta = !empty( $meta_input ) && is_array( $meta_input );
1160 $has_fields = count( $c ) > 1;
1161
1162 // Error if nothing to update
1163 if ( !$has_fields && !$has_meta ) {
1164 $hint = '';
1165 if ( isset( $a['fields'] ) || isset( $a['meta_input'] ) ) {
1166 $hint = ' (parameters were provided but parsed as empty - check for malformed JSON)';
1167 }
1168 $r['error'] = [ 'code' => -32602, 'message' => 'No fields or meta_input provided to update' . $hint ];
1169 break;
1170 }
1171
1172 // Update post fields if any
1173 $u = $post_id;
1174 if ( $has_fields ) {
1175 $u = wp_update_post( $c, true );
1176 if ( is_wp_error( $u ) ) {
1177 $r['error'] = [ 'code' => $u->get_error_code(), 'message' => $u->get_error_message() ];
1178 break;
1179 }
1180 }
1181
1182 // Update meta if any
1183 if ( $has_meta ) {
1184 foreach ( $meta_input as $k => $v ) {
1185 update_post_meta( $u, sanitize_key( $k ), maybe_serialize( $v ) );
1186 }
1187 }
1188
1189 // Verify the update actually took effect
1190 $updated_post = get_post( $u );
1191 $result = [
1192 'post_id' => $u,
1193 'post_modified' => $updated_post->post_modified,
1194 ];
1195
1196 // Verify content was saved correctly if we tried to update it
1197 if ( $content_to_verify !== null ) {
1198 $saved_content = $updated_post->post_content;
1199 $result['content_length'] = strlen( $saved_content );
1200 if ( $saved_content !== $content_to_verify ) {
1201 $result['warning'] = 'Content differs from input (sanitization applied or save failed)';
1202 $result['expected_length'] = strlen( $content_to_verify );
1203 }
1204 }
1205
1206 if ( !empty( $a['schedule_for'] ) ) {
1207 $result['scheduled_for'] = $a['schedule_for'];
1208 }
1209
1210 $this->add_result_text( $r, wp_json_encode( $result, JSON_PRETTY_PRINT ) );
1211 break;
1212
1213 /* ===== Posts: delete ===== */
1214 case 'wp_delete_post':
1215 if ( empty( $a['ID'] ) ) {
1216 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
1217 break;
1218 }
1219 $del = wp_delete_post( intval( $a['ID'] ), !empty( $a['force'] ) );
1220 if ( $del ) {
1221 $this->add_result_text( $r, 'Post #' . $a['ID'] . ' deleted' );
1222 }
1223 else {
1224 $r['error'] = [ 'code' => -32603, 'message' => 'Deletion failed' ];
1225 }
1226 break;
1227
1228 /* ===== Posts: alter (search/replace) ===== */
1229 case 'wp_alter_post':
1230 if ( empty( $a['ID'] ) || empty( $a['field'] ) || !isset( $a['search'] ) || !isset( $a['replace'] ) ) {
1231 $r['error'] = [ 'code' => -32602, 'message' => 'ID, field, search, and replace required' ];
1232 break;
1233 }
1234 $post_id = intval( $a['ID'] );
1235 $field = sanitize_key( $a['field'] );
1236 $search = $a['search'];
1237 $replace = $a['replace'];
1238 $is_regex = !empty( $a['regex'] );
1239
1240 // Validate field
1241 $allowed_fields = [ 'post_content', 'post_excerpt', 'post_title' ];
1242 if ( !in_array( $field, $allowed_fields, true ) ) {
1243 $r['error'] = [ 'code' => -32602, 'message' => 'Field must be: post_content, post_excerpt, or post_title' ];
1244 break;
1245 }
1246
1247 $post = get_post( $post_id );
1248 if ( !$post ) {
1249 $r['error'] = [ 'code' => -32602, 'message' => 'Post not found' ];
1250 break;
1251 }
1252
1253 $content = $post->$field;
1254 $count = 0;
1255
1256 if ( $is_regex ) {
1257 // Validate regex pattern
1258 set_error_handler( fn () => false );
1259 $test = preg_match( $search, '' );
1260 restore_error_handler();
1261 if ( $test === false ) {
1262 $r['error'] = [ 'code' => -32602, 'message' => 'Invalid regex pattern' ];
1263 break;
1264 }
1265 $new_content = preg_replace( $search, $replace, $content, -1, $count );
1266 if ( $new_content === null ) {
1267 $r['error'] = [ 'code' => -32603, 'message' => 'Regex error' ];
1268 break;
1269 }
1270 }
1271 else {
1272 $new_content = str_replace( $search, $replace, $content, $count );
1273 }
1274
1275 if ( $count === 0 ) {
1276 $this->add_result_text( $r, 'No occurrences found; post unchanged.' );
1277 break;
1278 }
1279
1280 $update = wp_update_post( [ 'ID' => $post_id, $field => $new_content ], true );
1281 if ( is_wp_error( $update ) ) {
1282 $r['error'] = [ 'code' => $update->get_error_code(), 'message' => $update->get_error_message() ];
1283 break;
1284 }
1285
1286 $this->add_result_text( $r, $count . ' replacement' . ( $count === 1 ? '' : 's' ) . ' applied to ' . $field . ' of post #' . $post_id );
1287 break;
1288
1289 /* ===== Post-meta ===== */
1290 case 'wp_get_post_meta':
1291 if ( empty( $a['ID'] ) ) {
1292 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
1293 break;
1294 }
1295 $pid = intval( $a['ID'] );
1296 $out = ( $a['key'] ?? '' ) ? get_post_meta( $pid, sanitize_key( $a['key'] ), true ) : get_post_meta( $pid );
1297 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
1298 break;
1299
1300 case 'wp_update_post_meta':
1301 if ( empty( $a['ID'] ) ) {
1302 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
1303 break;
1304 }
1305 $pid = intval( $a['ID'] );
1306
1307 // Handle JSON strings for meta (some MCP clients send objects as JSON strings)
1308 $meta = $a['meta'] ?? null;
1309 if ( is_string( $meta ) ) {
1310 $meta = json_decode( $meta, true );
1311 }
1312
1313 if ( !empty( $meta ) && is_array( $meta ) ) {
1314 foreach ( $meta as $k => $v ) {
1315 update_post_meta( $pid, sanitize_key( $k ), maybe_serialize( $v ) );
1316 }
1317 }
1318 elseif ( isset( $a['key'], $a['value'] ) ) {
1319 update_post_meta( $pid, sanitize_key( $a['key'] ), maybe_serialize( $a['value'] ) );
1320 }
1321 else {
1322 $r['error'] = [ 'code' => -32602, 'message' => 'meta array or key/value required' ];
1323 break;
1324 }
1325 $this->add_result_text( $r, 'Meta updated for post #' . $pid );
1326 break;
1327
1328 case 'wp_delete_post_meta':
1329 if ( empty( $a['ID'] ) || empty( $a['key'] ) ) {
1330 $r['error'] = [ 'code' => -32602, 'message' => 'ID & key required' ];
1331 break;
1332 }
1333 $pid = intval( $a['ID'] );
1334 $key = sanitize_key( $a['key'] );
1335 $done = isset( $a['value'] ) ? delete_post_meta( $pid, $key, maybe_serialize( $a['value'] ) ) : delete_post_meta( $pid, $key );
1336 if ( $done ) {
1337 $this->add_result_text( $r, 'Meta deleted on post #' . $pid );
1338 }
1339 else {
1340 $r['error'] = [ 'code' => -32603, 'message' => 'Deletion failed' ];
1341 }
1342 break;
1343
1344 /* ===== Featured image ===== */
1345 case 'wp_set_featured_image':
1346 if ( empty( $a['post_id'] ) ) {
1347 $r['error'] = [ 'code' => -32602, 'message' => 'post_id required' ];
1348 break;
1349 }
1350 $post_id = intval( $a['post_id'] );
1351 $media_id = isset( $a['media_id'] ) ? intval( $a['media_id'] ) : 0;
1352 if ( $media_id ) {
1353 $done = set_post_thumbnail( $post_id, $media_id );
1354 if ( $done ) {
1355 $this->add_result_text( $r, 'Featured image set on post #' . $post_id );
1356 }
1357 else {
1358 $r['error'] = [ 'code' => -32603, 'message' => 'Failed to set thumbnail' ];
1359 }
1360 }
1361 else {
1362 delete_post_thumbnail( $post_id );
1363 $this->add_result_text( $r, 'Featured image removed from post #' . $post_id );
1364 }
1365 break;
1366
1367 /* ===== Taxonomies ===== */
1368 case 'wp_get_taxonomies':
1369 $pt = sanitize_key( $a['post_type'] ?? 'post' );
1370 $out = [];
1371 foreach ( get_object_taxonomies( $pt, 'objects' ) as $t ) {
1372 $out[] = [ 'key' => $t->name, 'label' => $t->label ];
1373 }
1374 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
1375 break;
1376
1377 case 'wp_get_terms':
1378 $tax = sanitize_key( $a['taxonomy'] );
1379 $args = [
1380 'taxonomy' => $tax,
1381 'hide_empty' => false,
1382 'number' => intval( $a['limit'] ?? 0 ),
1383 'search' => $a['search'] ?? '',
1384 ];
1385 if ( isset( $a['parent'] ) ) {
1386 $args['parent'] = intval( $a['parent'] );
1387 }
1388 $out = [];
1389 foreach ( get_terms( $args ) as $t ) {
1390 $out[] = [ 'term_id' => $t->term_id, 'name' => $t->name, 'slug' => $t->slug, 'count' => $t->count ];
1391 }
1392 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
1393 break;
1394
1395 case 'wp_create_term':
1396 if ( empty( $a['term_name'] ) ) {
1397 $r['error'] = [ 'code' => -32602, 'message' => 'term_name required' ];
1398 break;
1399 }
1400 $tax = sanitize_key( $a['taxonomy'] );
1401 $args = [];
1402 if ( $a['slug'] ?? '' ) {
1403 $args['slug'] = sanitize_title( $a['slug'] );
1404 }
1405 if ( $a['description'] ?? '' ) {
1406 $args['description'] = sanitize_text_field( $a['description'] );
1407 }
1408 if ( isset( $a['parent'] ) ) {
1409 $args['parent'] = intval( $a['parent'] );
1410 }
1411 $term = wp_insert_term( sanitize_text_field( $a['term_name'] ), $tax, $args );
1412 if ( is_wp_error( $term ) ) {
1413 $r['error'] = [ 'code' => $term->get_error_code(), 'message' => $term->get_error_message() ];
1414 }
1415 else {
1416 $this->add_result_text( $r, 'Term ' . $term['term_id'] . ' created' );
1417 }
1418 break;
1419
1420 case 'wp_update_term':
1421 $tid = intval( $a['term_id'] ?? 0 );
1422 if ( !$tid ) {
1423 $r['error'] = [ 'code' => -32602, 'message' => 'term_id required' ];
1424 break;
1425 }
1426 $tax = sanitize_key( $a['taxonomy'] );
1427 $uargs = [];
1428 foreach ( [ 'name', 'slug', 'description', 'parent' ] as $f ) {
1429 if ( isset( $a[$f] ) ) {
1430 $uargs[$f] = $a[$f];
1431 }
1432 }
1433 $t = wp_update_term( $tid, $tax, $uargs );
1434 if ( is_wp_error( $t ) ) {
1435 $r['error'] = [ 'code' => $t->get_error_code(), 'message' => $t->get_error_message() ];
1436 }
1437 else {
1438 $this->add_result_text( $r, 'Term ' . $tid . ' updated' );
1439 }
1440 break;
1441
1442 case 'wp_delete_term':
1443 $tid = intval( $a['term_id'] ?? 0 );
1444 if ( !$tid ) {
1445 $r['error'] = [ 'code' => -32602, 'message' => 'term_id required' ];
1446 break;
1447 }
1448 $tax = sanitize_key( $a['taxonomy'] );
1449 $d = wp_delete_term( $tid, $tax );
1450 if ( $d ) {
1451 $this->add_result_text( $r, 'Term ' . $tid . ' deleted' );
1452 }
1453 else {
1454 $r['error'] = [ 'code' => -32603, 'message' => 'Deletion failed' ];
1455 }
1456 break;
1457
1458 case 'wp_get_post_terms':
1459 if ( empty( $a['ID'] ) ) {
1460 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
1461 break;
1462 }
1463 $tax = sanitize_key( $a['taxonomy'] ?? 'category' );
1464 $out = [];
1465 foreach ( wp_get_post_terms( intval( $a['ID'] ), $tax, [ 'fields' => 'all' ] ) as $t ) {
1466 $out[] = [ 'term_id' => $t->term_id, 'name' => $t->name ];
1467 }
1468 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
1469 break;
1470
1471 case 'wp_add_post_terms':
1472 if ( empty( $a['ID'] ) || empty( $a['terms'] ) ) {
1473 $r['error'] = [ 'code' => -32602, 'message' => 'ID & terms required' ];
1474 break;
1475 }
1476 $terms = $a['terms'];
1477 // Handle JSON strings (some MCP clients send arrays as JSON strings)
1478 if ( is_string( $terms ) ) {
1479 $terms = json_decode( $terms, true ) ?? [];
1480 }
1481 $tax = sanitize_key( $a['taxonomy'] ?? 'category' );
1482 $append = !isset( $a['append'] ) || $a['append'];
1483 $set = wp_set_post_terms( intval( $a['ID'] ), $terms, $tax, $append );
1484 if ( is_wp_error( $set ) ) {
1485 $r['error'] = [ 'code' => $set->get_error_code(), 'message' => $set->get_error_message() ];
1486 }
1487 else {
1488 $this->add_result_text( $r, 'Terms set for post #' . $a['ID'] );
1489 }
1490 break;
1491
1492 /* ===== Media: list ===== */
1493 case 'wp_get_media':
1494 $q = [
1495 'post_type' => 'attachment',
1496 's' => $a['search'] ?? '',
1497 'posts_per_page' => max( 1, intval( $a['limit'] ?? 10 ) ),
1498 'post_status' => 'inherit',
1499 ];
1500 $d = [];
1501 if ( $a['after'] ?? '' ) {
1502 $d['after'] = $a['after'];
1503 }
1504 if ( $a['before'] ?? '' ) {
1505 $d['before'] = $a['before'];
1506 }
1507 if ( $d ) {
1508 $q['date_query'] = [ $d ];
1509 }
1510 $list = [];
1511 foreach ( get_posts( $q ) as $m ) {
1512 $list[] = [ 'ID' => $m->ID, 'title' => $m->post_title, 'url' => wp_get_attachment_url( $m->ID ) ];
1513 }
1514 $this->add_result_text( $r, wp_json_encode( $list, JSON_PRETTY_PRINT ) );
1515 break;
1516
1517 /* ===== Media: upload ===== */
1518 case 'wp_upload_media':
1519 $has_url = !empty( $a['url'] );
1520 $has_base64 = !empty( $a['base64'] ) && !empty( $a['filename'] );
1521 if ( !$has_url && !$has_base64 ) {
1522 $r['error'] = [ 'code' => -32602, 'message' => 'Provide either url, or base64 + filename.' ];
1523 break;
1524 }
1525 try {
1526 require_once ABSPATH . 'wp-admin/includes/file.php';
1527 require_once ABSPATH . 'wp-admin/includes/media.php';
1528 require_once ABSPATH . 'wp-admin/includes/image.php';
1529
1530 if ( $has_url ) {
1531 $tmp = download_url( $a['url'] );
1532 if ( is_wp_error( $tmp ) ) {
1533 throw new Exception( $tmp->get_error_message(), $tmp->get_error_code() );
1534 }
1535 $file = [ 'name' => basename( parse_url( $a['url'], PHP_URL_PATH ) ), 'tmp_name' => $tmp ];
1536 }
1537 else {
1538 $decoded = base64_decode( $a['base64'], true );
1539 if ( $decoded === false ) {
1540 throw new Exception( 'Invalid base64 data.' );
1541 }
1542 $tmp = wp_tempnam( $a['filename'] );
1543 file_put_contents( $tmp, $decoded );
1544 $file = [ 'name' => sanitize_file_name( $a['filename'] ), 'tmp_name' => $tmp ];
1545 }
1546
1547 $id = media_handle_sideload( $file, 0, $a['description'] ?? '' );
1548 @unlink( $tmp );
1549 if ( is_wp_error( $id ) ) {
1550 throw new Exception( $id->get_error_message(), $id->get_error_code() );
1551 }
1552 if ( $a['title'] ?? '' ) {
1553 wp_update_post( [ 'ID' => $id, 'post_title' => sanitize_text_field( $a['title'] ) ] );
1554 }
1555 if ( $a['alt'] ?? '' ) {
1556 update_post_meta( $id, '_wp_attachment_image_alt', sanitize_text_field( $a['alt'] ) );
1557 }
1558 $this->add_result_text( $r, wp_get_attachment_url( $id ) );
1559 }
1560 catch ( \Throwable $e ) {
1561 $r['error'] = [ 'code' => $e->getCode() ?: -32603, 'message' => $e->getMessage() ];
1562 }
1563 break;
1564
1565 /* ===== Media: upload alternative (two-step) ===== */
1566 case 'wp_upload_request':
1567 if ( empty( $a['filename'] ) ) {
1568 $r['error'] = [ 'code' => -32602, 'message' => 'filename required' ];
1569 break;
1570 }
1571 try {
1572 $token = wp_generate_password( 32, false );
1573 $transient_key = 'mwai_mcp_upload_' . $token;
1574 $data = [
1575 'filename' => sanitize_file_name( $a['filename'] ),
1576 'title' => $a['title'] ?? '',
1577 'description' => $a['description'] ?? '',
1578 'alt' => $a['alt'] ?? '',
1579 ];
1580 set_transient( $transient_key, $data, 5 * MINUTE_IN_SECONDS );
1581 $upload_url = rest_url( 'mcp/v1/upload/' . $token );
1582 $this->add_result_text( $r, wp_json_encode( [
1583 'upload_url' => $upload_url,
1584 'expires_in' => '5 minutes',
1585 'usage' => 'curl -X POST -F "file=@/path/to/' . $a['filename'] . '" "' . $upload_url . '"',
1586 ], JSON_PRETTY_PRINT ) );
1587 }
1588 catch ( \Throwable $e ) {
1589 $r['error'] = [ 'code' => $e->getCode() ?: -32603, 'message' => $e->getMessage() ];
1590 }
1591 break;
1592
1593 /* ===== Media: update ===== */
1594 case 'wp_update_media':
1595 if ( empty( $a['ID'] ) ) {
1596 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
1597 break;
1598 }
1599 $upd = [ 'ID' => intval( $a['ID'] ) ];
1600 if ( $a['title'] ?? '' ) {
1601 $upd['post_title'] = sanitize_text_field( $a['title'] );
1602 }
1603 if ( $a['caption'] ?? '' ) {
1604 $upd['post_excerpt'] = $this->clean_html( $a['caption'] );
1605 }
1606 if ( $a['description'] ?? '' ) {
1607 $upd['post_content'] = $this->clean_html( $a['description'] );
1608 }
1609 $u = wp_update_post( $upd, true );
1610 if ( is_wp_error( $u ) ) {
1611 $r['error'] = [ 'code' => $u->get_error_code(), 'message' => $u->get_error_message() ];
1612 }
1613 else {
1614 if ( $a['alt'] ?? '' ) {
1615 update_post_meta( $u, '_wp_attachment_image_alt', sanitize_text_field( $a['alt'] ) );
1616 }
1617 $this->add_result_text( $r, 'Media #' . $u . ' updated' );
1618 }
1619 break;
1620
1621 /* ===== Media: delete ===== */
1622 case 'wp_delete_media':
1623 if ( empty( $a['ID'] ) ) {
1624 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
1625 break;
1626 }
1627 $d = wp_delete_post( intval( $a['ID'] ), !empty( $a['force'] ) );
1628 if ( $d ) {
1629 $this->add_result_text( $r, 'Media #' . $a['ID'] . ' deleted' );
1630 }
1631 else {
1632 $r['error'] = [ 'code' => -32603, 'message' => 'Deletion failed' ];
1633 }
1634 break;
1635
1636 /* ===== MWAI Vision ===== */
1637 case 'mwai_vision':
1638 if ( empty( $a['message'] ) ) {
1639 $r['error'] = [ 'code' => -32602, 'message' => 'message required' ];
1640 break;
1641 }
1642 global $mwai;
1643 if ( !isset( $mwai ) ) {
1644 $r['error'] = [ 'code' => -32603, 'message' => 'MWAI not found' ];
1645 break;
1646 }
1647 $analysis = $mwai->simpleVisionQuery(
1648 $a['message'],
1649 $a['url'] ?? null,
1650 $a['path'] ?? null,
1651 [ 'scope' => 'mcp' ]
1652 );
1653 $this->add_result_text( $r, is_string( $analysis ) ? $analysis : wp_json_encode( $analysis, JSON_PRETTY_PRINT ) );
1654 break;
1655
1656 /* ===== MWAI Image ===== */
1657 case 'mwai_image':
1658 if ( empty( $a['message'] ) ) {
1659 $r['error'] = [ 'code' => -32602, 'message' => 'message required' ];
1660 break;
1661 }
1662 global $mwai;
1663 if ( !isset( $mwai ) ) {
1664 $r['error'] = [ 'code' => -32603, 'message' => 'MWAI not found' ];
1665 break;
1666 }
1667
1668 $media = $mwai->imageQueryForMediaLibrary( $a['message'], [ 'scope' => 'mcp' ], $a['postId'] ?? null );
1669 if ( is_wp_error( $media ) ) {
1670 $r['error'] = [ 'code' => $media->get_error_code(), 'message' => $media->get_error_message() ];
1671 break;
1672 }
1673
1674 $mid = intval( $media['id'] );
1675
1676 $upd = [ 'ID' => $mid ];
1677 if ( !empty( $a['title'] ) ) {
1678 $upd['post_title'] = sanitize_text_field( $a['title'] );
1679 }
1680 if ( !empty( $a['caption'] ) ) {
1681 $upd['post_excerpt'] = $this->clean_html( $a['caption'] );
1682 }
1683 if ( !empty( $a['description'] ) ) {
1684 $upd['post_content'] = $this->clean_html( $a['description'] );
1685 }
1686 if ( count( $upd ) > 1 ) {
1687 wp_update_post( $upd, true );
1688 }
1689 if ( array_key_exists( 'alt', $a ) ) {
1690 update_post_meta( $mid, '_wp_attachment_image_alt', sanitize_text_field( (string) $a['alt'] ) );
1691 }
1692
1693 $media = [
1694 'id' => $mid,
1695 'url' => wp_get_attachment_url( $mid ),
1696 'title' => get_the_title( $mid ),
1697 'caption' => wp_get_attachment_caption( $mid ),
1698 'alt' => get_post_meta( $mid, '_wp_attachment_image_alt', true ),
1699 ];
1700 $this->add_result_text( $r, wp_json_encode( $media, JSON_PRETTY_PRINT ) );
1701 break;
1702
1703 default: $r['error'] = [ 'code' => -32601, 'message' => 'Unknown tool' ];
1704 }
1705 return $r;
1706 }
1707 #endregion
1708 }
1709