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