PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.5.6
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.5.6
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 2 months ago mcp-oauth.php 3 months ago mcp-rest.php 2 months ago mcp.conf 1 year ago mcp.js 10 months ago mcp.md 10 months ago mcp.php 2 months ago wpai-connectors.php 3 months ago wpai-gateway-availability.php 4 months ago wpai-gateway-directory.php 4 months ago wpai-gateway-image-model.php 4 months ago wpai-gateway-model.php 4 months ago wpai-gateway-providers.php 4 months ago wpai-gateway.php 4 months ago
mcp-core.php
2053 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
28 // Prepare post_content for wp_create_post. If the caller already sent HTML,
29 // Gutenberg blocks, or shortcodes, keep it as-is (sanitized like the update
30 // path) instead of running the markdown parser. Parsedown would HTML-encode
31 // the quotes in shortcode attributes ([x a="b"] -> a=&quot;b&quot;), auto-link
32 // URLs, and <p>-wrap lines, silently breaking shortcode rendering. Markdown
33 // conversion is reserved for plain prose with no existing markup.
34 private function prepare_new_content( string $v ): string {
35 $hasBlocks = strpos( $v, '<!-- wp:' ) !== false;
36 $hasHtml = (bool) preg_match( '/<(?:p|div|h[1-6]|ul|ol|li|figure|table|blockquote|section|img|a|br|span|strong|em)\b[^>]*>/i', $v );
37 // Generic shortcode detection (independent of whether the shortcode is
38 // registered on THIS site): an attribute assignment inside brackets
39 // [name attr="x"] or a closing [/name]. Deliberately does not match a
40 // Markdown link [text](url), which has neither "=" nor a leading slash.
41 $hasShortcode = (bool) preg_match( '/\[[a-zA-Z][\w-]*\s+[^\]]*?=[^\]]*\]|\[\/[a-zA-Z]/', $v );
42 if ( $hasBlocks || $hasHtml || $hasShortcode ) {
43 return $this->clean_html( $v );
44 }
45 return $this->core->markdown_to_html( $v );
46 }
47
48 // Recursively blank out every block's attributes. Gallery/media blocks (e.g.
49 // meow-gallery) store their whole image list as JSON in the block-delimiter
50 // comment, which can be hundreds of KB and overflows the tool's token cap on
51 // read. Keep the small delimiter marker and the inner prose/HTML.
52 private function strip_block_attrs( array $blocks ): array {
53 foreach ( $blocks as &$b ) {
54 $b['attrs'] = [];
55 if ( !empty( $b['innerBlocks'] ) ) {
56 $b['innerBlocks'] = $this->strip_block_attrs( $b['innerBlocks'] );
57 }
58 }
59 unset( $b );
60 return $blocks;
61 }
62
63 // Return the post content with block-attribute JSON stripped, so a gallery-heavy
64 // post collapses to its few KB of actual prose without re-rendering any block.
65 private function prose_content( string $v ): string {
66 return trim( serialize_blocks( $this->strip_block_attrs( parse_blocks( wp_unslash( $v ) ) ) ) );
67 }
68 private function post_excerpt( WP_Post $p ): string {
69 return wp_trim_words( wp_strip_all_tags( $p->post_excerpt ?: $p->post_content ), 55 );
70 }
71 private function empty_schema(): array {
72 return [ 'type' => 'object', 'properties' => (object) [] ];
73 }
74
75 /**
76 * Compile a wp_alter_post regex search into a delimited PCRE pattern.
77 *
78 * The documented contract is a BARE pattern plus an optional flags string; we wrap it
79 * with a safe delimiter internally. This is what makes Gutenberg block markers work:
80 * they contain "/" (e.g. <!-- /wp:paragraph -->), which collides with the "/" delimiter,
81 * so "/" is tried last when picking a delimiter. For backward compatibility a pattern
82 * that already compiles as a fully delimited PCRE (and no separate flags were given) is
83 * honored as-is. Returns [ compiled, error ]; exactly one is non-null.
84 */
85 private function compile_alter_regex( string $pattern, string $flags = '' ): array {
86 $flags = trim( $flags );
87 if ( $flags !== '' && !preg_match( '/^[imsxuADSUXJ]+$/', $flags ) ) {
88 return [ null, 'Invalid regex flags "' . $flags . '". Allowed: i, m, s, x, u, A, D, S, U, X, J.' ];
89 }
90
91 // Backward compat: an already-delimited pattern that compiles is used verbatim.
92 if ( $flags === '' && $pattern !== '' && $this->preg_compile_error( $pattern ) === null ) {
93 return [ $pattern, null ];
94 }
95
96 // Bare pattern: wrap with the first delimiter not present in the pattern ("/" last).
97 $delimiter = '';
98 foreach ( [ '~', '#', '%', '!', '@', '/' ] as $candidate ) {
99 if ( strpos( $pattern, $candidate ) === false ) {
100 $delimiter = $candidate;
101 break;
102 }
103 }
104 if ( $delimiter === '' ) {
105 // Pattern uses every candidate; fall back to "~" and escape its occurrences.
106 $delimiter = '~';
107 $pattern = str_replace( '~', '\~', $pattern );
108 }
109 $compiled = $delimiter . $pattern . $delimiter . $flags;
110
111 $err = $this->preg_compile_error( $compiled );
112 if ( $err !== null ) {
113 return [ null, 'Invalid regex pattern: ' . $err . ' (compiled to ' . $compiled . ')' ];
114 }
115 return [ $compiled, null ];
116 }
117
118 /**
119 * Test-compile a PCRE pattern without emitting warnings. Returns null on success, or a
120 * human-readable PCRE error message (echoing the real engine message when available).
121 */
122 private function preg_compile_error( string $pattern ): ?string {
123 set_error_handler( fn () => true );
124 $result = preg_match( $pattern, '' );
125 restore_error_handler();
126 if ( $result !== false ) {
127 return null;
128 }
129 return function_exists( 'preg_last_error_msg' )
130 ? preg_last_error_msg()
131 : 'PCRE error code ' . preg_last_error();
132 }
133
134 /**
135 * Bust post caches after a write so a follow-up wp_get_post in the next request
136 * returns fresh data on sites with persistent object caches (Redis, Memcached) or
137 * page caches (LiteSpeed, WP Rocket, Cloudflare, etc.). wp_insert_post / wp_update_post
138 * call clean_post_cache themselves; this is idempotent and also fans out third-party
139 * purge hooks plus a generic mwai_mcp_post_changed action so sites can wire their own.
140 *
141 * Per-request dedupe: agentic clients often hit the same post several times in quick
142 * succession (e.g. wp_alter_post twice on the same page within the same JSON-RPC call),
143 * which would multiply expensive third-party purges (Cloudflare global, Algolia reindex).
144 * We keep a static set of post IDs already busted in this PHP request and short-circuit
145 * repeats. The $context array is forwarded to mwai_mcp_post_changed so handlers can
146 * coalesce or defer purges across requests on their own (e.g. flush at end of batch).
147 */
148 private function bust_post_cache( int $post_id, array $context = [] ): void {
149 if ( $post_id <= 0 ) {
150 return;
151 }
152 static $already_busted = [];
153 if ( isset( $already_busted[ $post_id ] ) ) {
154 return;
155 }
156 $already_busted[ $post_id ] = true;
157
158 clean_post_cache( $post_id );
159 $context = wp_parse_args( $context, [
160 'source' => 'mcp',
161 'tool' => null,
162 'batch' => false,
163 ] );
164 do_action( 'mwai_mcp_post_changed', $post_id, $context );
165 do_action( 'litespeed_purge_post', $post_id );
166 if ( function_exists( 'rocket_clean_post' ) ) {
167 rocket_clean_post( $post_id );
168 }
169 }
170 #endregion
171
172 #region Tools Definitions
173 private function tools(): array {
174 return [
175
176 /* -------- Plugins -------- */
177 'wp_list_plugins' => [
178 'name' => 'wp_list_plugins',
179 'description' => 'List installed plugins (returns array of {Name, Version}).',
180 'inputSchema' => [
181 'type' => 'object',
182 'properties' => [ 'search' => [ 'type' => 'string' ] ],
183 ],
184 'accessLevel' => 'read',
185 ],
186
187 /* -------- Users -------- */
188 'wp_get_users' => [
189 'name' => 'wp_get_users',
190 'description' => 'Retrieve users (fields: ID, user_login, display_name, roles). If no limit supplied, returns 10. `paged` ignored if `offset` is used.',
191 'inputSchema' => [
192 'type' => 'object',
193 'properties' => [
194 'search' => [ 'type' => 'string' ],
195 'role' => [ 'type' => 'string' ],
196 'limit' => [ 'type' => 'integer' ],
197 'offset' => [ 'type' => 'integer' ],
198 'paged' => [ 'type' => 'integer' ],
199 ],
200 ],
201 'accessLevel' => 'admin',
202 ],
203 'wp_create_user' => [
204 'name' => 'wp_create_user',
205 'description' => 'Create a user. Requires user_login and user_email. Optional: user_pass (random if omitted), display_name, role.',
206 'inputSchema' => [
207 'type' => 'object',
208 'properties' => [
209 'user_login' => [ 'type' => 'string' ],
210 'user_email' => [ 'type' => 'string' ],
211 'user_pass' => [ 'type' => 'string' ],
212 'display_name' => [ 'type' => 'string' ],
213 'role' => [ 'type' => 'string' ],
214 ],
215 'required' => [ 'user_login', 'user_email' ],
216 ],
217 'accessLevel' => 'admin',
218 ],
219 'wp_update_user' => [
220 'name' => 'wp_update_user',
221 'description' => 'Update a user – pass ID plus a “fields” object (user_email, display_name, user_pass, role).',
222 'inputSchema' => [
223 'type' => 'object',
224 'properties' => [
225 'ID' => [ 'type' => 'integer' ],
226 'fields' => [
227 'type' => 'object',
228 'properties' => [
229 'user_email' => [ 'type' => 'string' ],
230 'display_name' => [ 'type' => 'string' ],
231 'user_pass' => [ 'type' => 'string' ],
232 'role' => [ 'type' => 'string' ],
233 ],
234 'additionalProperties' => true
235 ],
236 ],
237 'required' => [ 'ID' ],
238 ],
239 'accessLevel' => 'admin',
240 ],
241
242 /* -------- Comments -------- */
243 'wp_get_comments' => [
244 'name' => 'wp_get_comments',
245 'description' => 'Retrieve comments (fields: comment_ID, comment_post_ID, comment_author, comment_content, comment_date, comment_approved). Returns 10 by default. Filter by commenter with `user_id` (registered user ID) or `author_email`.',
246 'inputSchema' => [
247 'type' => 'object',
248 'properties' => [
249 'post_id' => [ 'type' => 'integer' ],
250 'status' => [ 'type' => 'string' ],
251 'search' => [ 'type' => 'string' ],
252 'user_id' => [ 'type' => 'integer', 'description' => 'Filter by the registered user ID of the commenter.' ],
253 'author_email' => [ 'type' => 'string', 'description' => 'Filter by the commenter email address.' ],
254 'limit' => [ 'type' => 'integer' ],
255 'offset' => [ 'type' => 'integer' ],
256 'paged' => [ 'type' => 'integer' ],
257 ],
258 ],
259 'accessLevel' => 'read',
260 ],
261 'wp_create_comment' => [
262 'name' => 'wp_create_comment',
263 'description' => 'Insert a comment. Requires post_id and comment_content. Optional author, author_email, author_url.',
264 'inputSchema' => [
265 'type' => 'object',
266 'properties' => [
267 'post_id' => [ 'type' => 'integer' ],
268 'comment_content' => [ 'type' => 'string' ],
269 'comment_author' => [ 'type' => 'string' ],
270 'comment_author_email' => [ 'type' => 'string' ],
271 'comment_author_url' => [ 'type' => 'string' ],
272 'comment_approved' => [ 'type' => 'string' ],
273 ],
274 'required' => [ 'post_id', 'comment_content' ],
275 ],
276 'accessLevel' => 'write',
277 ],
278 'wp_update_comment' => [
279 'name' => 'wp_update_comment',
280 'description' => 'Update a comment – pass comment_ID plus fields (comment_content, comment_approved).',
281 'inputSchema' => [
282 'type' => 'object',
283 'properties' => [
284 'comment_ID' => [ 'type' => 'integer' ],
285 'fields' => [
286 'type' => 'object',
287 'properties' => [
288 'comment_content' => [ 'type' => 'string' ],
289 'comment_approved' => [ 'type' => 'string' ],
290 ],
291 'additionalProperties' => true
292 ],
293 ],
294 'required' => [ 'comment_ID' ],
295 ],
296 'accessLevel' => 'write',
297 ],
298 'wp_delete_comment' => [
299 'name' => 'wp_delete_comment',
300 'description' => 'Delete a comment. `force` true bypasses trash.',
301 'inputSchema' => [
302 'type' => 'object',
303 'properties' => [
304 'comment_ID' => [ 'type' => 'integer' ],
305 'force' => [ 'type' => 'boolean' ],
306 ],
307 'required' => [ 'comment_ID' ],
308 ],
309 'accessLevel' => 'admin',
310 ],
311
312 /* -------- Options -------- */
313 'wp_get_option' => [
314 'name' => 'wp_get_option',
315 'description' => 'Get a single WordPress option value (scalar or array) by key. Set raw to true to read the stored value straight from the database, bypassing the object cache and any option_* filters (e.g. Polylang filters sticky_posts per-language on REST requests, so a normal read can differ from the DB / wp-cli).',
316 'inputSchema' => [
317 'type' => 'object',
318 'properties' => [
319 'key' => [ 'type' => 'string' ],
320 'raw' => [ 'type' => 'boolean', 'description' => 'Read the unfiltered value directly from the database (bypasses object cache and option_* filters).' ],
321 ],
322 'required' => [ 'key' ],
323 ],
324 'accessLevel' => 'admin',
325 ],
326 'wp_update_option' => [
327 'name' => 'wp_update_option',
328 'description' => 'Create or update a WordPress option. Arrays/objects are stored natively (a JSON string is decoded back to an array first). WordPress refreshes the option cache automatically, but full-page caches (Varnish, WP Rocket, Cloudflare) are not purged, so a front-end may lag until its cache expires; integrations can hook the mwai_mcp_mutate action to purge on writes.',
329 'inputSchema' => [
330 'type' => 'object',
331 'properties' => [
332 'key' => [ 'type' => 'string' ],
333 // No type constraint here on purpose: WordPress options accept any
334 // value (string, number, boolean, array, object). Declaring a union
335 // that includes "object"/"array" makes ChatGPT reject the schema,
336 // and the runtime normalizer would strip the type anyway and log a
337 // warning every list_tools call. Keep it permissive from the start.
338 'value' => [ 'description' => 'Option value. Accepts strings, numbers, booleans, arrays, or objects (non-scalars are JSON-serialised).' ],
339 ],
340 'required' => [ 'key', 'value' ],
341 ],
342 'accessLevel' => 'admin',
343 ],
344
345 /* -------- Counts -------- */
346 'wp_count_posts' => [
347 'name' => 'wp_count_posts',
348 'description' => 'Return counts of posts by status. Optional post_type (default post).',
349 'inputSchema' => [
350 'type' => 'object',
351 'properties' => [ 'post_type' => [ 'type' => 'string' ] ],
352 ],
353 'accessLevel' => 'read',
354 ],
355 'wp_count_terms' => [
356 'name' => 'wp_count_terms',
357 'description' => 'Return total number of terms in a taxonomy.',
358 'inputSchema' => [
359 'type' => 'object',
360 'properties' => [ 'taxonomy' => [ 'type' => 'string' ] ],
361 'required' => [ 'taxonomy' ],
362 ],
363 'accessLevel' => 'read',
364 ],
365 'wp_count_media' => [
366 'name' => 'wp_count_media',
367 'description' => 'Return number of attachments (optionally after/before date).',
368 'inputSchema' => [
369 'type' => 'object',
370 'properties' => [
371 'after' => [ 'type' => 'string' ],
372 'before' => [ 'type' => 'string' ],
373 ],
374 ],
375 'accessLevel' => 'read',
376 ],
377
378 /* -------- Post-types -------- */
379 'wp_get_post_types' => [
380 'name' => 'wp_get_post_types',
381 'description' => 'List public post types (key, label).',
382 'inputSchema' => $this->empty_schema(),
383 'accessLevel' => 'read',
384 ],
385
386 /* -------- Posts -------- */
387 'wp_get_posts' => [
388 'name' => 'wp_get_posts',
389 '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. Filter by author with `author` (user ID) or `author_name` (user slug).',
390 'inputSchema' => [
391 'type' => 'object',
392 'properties' => [
393 'post_type' => [ 'type' => 'string' ],
394 'post_status' => [ 'type' => 'string' ],
395 'search' => [ 'type' => 'string' ],
396 'author' => [ 'type' => 'integer', 'description' => 'Filter by author user ID.' ],
397 'author_name' => [ 'type' => 'string', 'description' => 'Filter by author user slug (nicename). Ignored if author is set.' ],
398 'author__not_in' => [ 'type' => 'array', 'items' => [ 'type' => 'integer' ], 'description' => 'Exclude posts by these author user IDs.' ],
399 'after' => [ 'type' => 'string' ],
400 'before' => [ 'type' => 'string' ],
401 'limit' => [ 'type' => 'integer' ],
402 'offset' => [ 'type' => 'integer' ],
403 'paged' => [ 'type' => 'integer' ],
404 ],
405 ],
406 'accessLevel' => 'read',
407 ],
408 'wp_get_post' => [
409 'name' => 'wp_get_post',
410 'description' => 'Get basic post data by ID: title, content, status, dates, permalink. Reads through the WordPress object cache; if you just wrote with wp_create_post / wp_update_post / wp_alter_post, the write tools bust caches automatically so a follow-up read returns fresh data. For complete data including all meta and terms, use wp_get_post_snapshot instead. Set content_format to "prose" to strip block-attribute JSON (e.g. huge gallery blobs) and return just the prose.',
411 'inputSchema' => [
412 'type' => 'object',
413 'properties' => [
414 'ID' => [ 'type' => 'integer' ],
415 'content_format' => [ 'type' => 'string', 'enum' => [ 'full', 'prose' ], 'description' => 'full (default) returns raw content; prose strips block-attribute JSON, keeping prose, headings and block markers.' ],
416 ],
417 'required' => [ 'ID' ],
418 ],
419 'accessLevel' => 'read',
420 ],
421 'wp_get_post_snapshot' => [
422 'name' => 'wp_get_post_snapshot',
423 '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.',
424 'inputSchema' => [
425 'type' => 'object',
426 'properties' => [
427 'ID' => [ 'type' => 'integer', 'description' => 'Post ID' ],
428 'include' => [
429 'type' => 'array',
430 'description' => 'Optional: fields to include (default: all). Options: meta, terms, thumbnail, author',
431 'items' => [ 'type' => 'string' ],
432 ],
433 'exclude' => [
434 'type' => 'array',
435 'description' => 'Optional: fields to exclude from post data. Options: content (useful for posts with huge content like many galleries)',
436 'items' => [ 'type' => 'string' ],
437 ],
438 'content_format' => [ 'type' => 'string', 'enum' => [ 'full', 'prose' ], 'description' => 'full (default) returns raw content; prose strips block-attribute JSON (huge gallery blobs), keeping prose and block markers. Ignored if content is excluded.' ],
439 ],
440 'required' => [ 'ID' ],
441 ],
442 'accessLevel' => 'read',
443 ],
444 'wp_create_post' => [
445 'name' => 'wp_create_post',
446 'description' => 'Create a new post, page, or any custom post type. post_title is required. post_content accepts HTML, Gutenberg blocks, and shortcodes (stored as-is, attribute quotes preserved); plain prose with no markup is converted from Markdown. post_status defaults to "draft" and post_type defaults to "post" – pass post_type: "page" for a page, or any registered CPT slug (product, event, etc.). Set categories later with wp_add_post_terms; meta_input is an associative array of custom-field key/value pairs. For small surgical edits to an existing post (insert/replace a paragraph or shortcode without resending the whole body), use wp_alter_post instead.',
447 'inputSchema' => [
448 'type' => 'object',
449 'properties' => [
450 'post_title' => [ 'type' => 'string' ],
451 'post_content' => [ 'type' => 'string' ],
452 'post_excerpt' => [ 'type' => 'string' ],
453 'post_status' => [ 'type' => 'string' ],
454 'post_type' => [ 'type' => 'string' ],
455 'post_name' => [ 'type' => 'string' ],
456 'meta_input' => [ 'type' => 'object', 'description' => 'Associative array of custom fields.' ],
457 ],
458 'required' => [ 'post_title' ],
459 ],
460 'accessLevel' => 'write',
461 ],
462 'wp_update_post' => [
463 'name' => 'wp_update_post',
464 '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. Post fields may also be passed at the top level (e.g. ID + post_title directly). 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.',
465 'inputSchema' => [
466 'type' => 'object',
467 'properties' => [
468 'ID' => [ 'type' => 'integer', 'description' => 'The ID of the post to update.' ],
469 'fields' => [
470 'type' => 'object',
471 'properties' => [
472 'post_title' => [ 'type' => 'string' ],
473 'post_content' => [ 'type' => 'string' ],
474 'post_status' => [ 'type' => 'string' ],
475 'post_name' => [ 'type' => 'string' ],
476 'post_excerpt' => [ 'type' => 'string' ],
477 'post_category' => [ 'type' => 'array', 'items' => [ 'type' => 'integer' ] ],
478 ],
479 'additionalProperties' => true
480 ],
481 'meta_input' => [
482 'type' => 'object',
483 'description' => 'Associative array of custom fields.'
484 ],
485 'schedule_for' => [
486 'type' => 'string',
487 '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.'
488 ],
489 ],
490 'required' => [ 'ID' ],
491 ],
492 'accessLevel' => 'write',
493 ],
494 'wp_delete_post' => [
495 'name' => 'wp_delete_post',
496 'description' => 'Delete, trash, or remove a post, page, or any custom post type by ID. Without force, the post is moved to trash (can be restored). With force: true, the post is permanently destroyed (bypasses trash, irreversible). Works for posts, pages, products, events, attachments, or any registered CPT.',
497 'inputSchema' => [
498 'type' => 'object',
499 'properties' => [
500 'ID' => [ 'type' => 'integer' ],
501 'force' => [ 'type' => 'boolean' ],
502 ],
503 'required' => [ 'ID' ],
504 ],
505 'accessLevel' => 'admin',
506 ],
507 'wp_alter_post' => [
508 'name' => 'wp_alter_post',
509 'description' => 'Search-and-replace inside a post field without re-uploading the entire content. Efficient for making small edits to long content. With regex=true, pass a BARE PHP-PCRE pattern (no delimiters) in "search" and put any modifiers in "flags" (e.g. flags="i"); the pattern is wrapped with a safe delimiter internally, so patterns containing "/" (like Gutenberg block markers <!-- /wp:paragraph -->) work without escaping. Example: search="(<!-- /wp:paragraph -->)\\s*$" with flags="" appends to the last paragraph block. Backslashes must be JSON-escaped (\\s, \\d). A fully delimited pattern (/.../i) is also accepted for backward compatibility.',
510 'inputSchema' => [
511 'type' => 'object',
512 'properties' => [
513 'ID' => [ 'type' => 'integer', 'description' => 'Post ID.' ],
514 'field' => [ 'type' => 'string', 'description' => 'Field to modify: post_content, post_excerpt, or post_title.' ],
515 'search' => [ 'type' => 'string', 'description' => 'Text to search for, or (with regex=true) a bare PCRE pattern without delimiters, e.g. <!-- /wp:paragraph -->\\s*$' ],
516 'replace' => [ 'type' => 'string', 'description' => 'Replacement text. In regex mode, backreferences like $1 / \\1 are supported.' ],
517 'regex' => [ 'type' => 'boolean', 'description' => 'Treat search as a regex pattern (default: false).' ],
518 'flags' => [ 'type' => 'string', 'description' => 'Optional PCRE modifier letters applied in regex mode, e.g. "i" (case-insensitive), "s" (dotall), "m" (multiline). Allowed: i, m, s, x, u, A, D, S, U, X, J.' ],
519 ],
520 'required' => [ 'ID', 'field', 'search', 'replace' ],
521 ],
522 'accessLevel' => 'write',
523 ],
524
525 /* -------- Post-meta -------- */
526 'wp_get_post_meta' => [
527 'name' => 'wp_get_post_meta',
528 '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.',
529 'inputSchema' => [
530 'type' => 'object',
531 'properties' => [
532 'ID' => [ 'type' => 'integer' ],
533 'key' => [ 'type' => 'string' ],
534 ],
535 'required' => [ 'ID' ],
536 ],
537 'accessLevel' => 'read',
538 ],
539 'wp_update_post_meta' => [
540 'name' => 'wp_update_post_meta',
541 '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.',
542 'inputSchema' => [
543 'type' => 'object',
544 'properties' => [
545 'ID' => [ 'type' => 'integer' ],
546 'meta' => [ 'type' => 'object', 'description' => 'Key/value pairs to set. Alternative: provide "key" + "value".' ],
547 'key' => [ 'type' => 'string' ],
548 'value' => [ 'type' => [ 'string', 'number', 'boolean' ] ],
549 ],
550 'required' => [ 'ID' ],
551 ],
552 'accessLevel' => 'write',
553 ],
554 'wp_delete_post_meta' => [
555 'name' => 'wp_delete_post_meta',
556 'description' => 'Delete custom field(s) from a post. Provide value to remove a single row; omit value to delete all rows for the key.',
557 'inputSchema' => [
558 'type' => 'object',
559 'properties' => [
560 'ID' => [ 'type' => 'integer' ],
561 'key' => [ 'type' => 'string' ],
562 'value' => [ 'type' => [ 'string', 'number', 'boolean' ] ],
563 ],
564 'required' => [ 'ID', 'key' ],
565 ],
566 'accessLevel' => 'admin',
567 ],
568
569 /* -------- Featured image -------- */
570 'wp_set_featured_image' => [
571 'name' => 'wp_set_featured_image',
572 'description' => 'Attach or remove a featured image (thumbnail) for a post/page. Provide media_id to attach, omit or null to remove.',
573 'inputSchema' => [
574 'type' => 'object',
575 'properties' => [
576 'post_id' => [ 'type' => 'integer' ],
577 'media_id' => [ 'type' => 'integer' ],
578 ],
579 'required' => [ 'post_id' ],
580 ],
581 'accessLevel' => 'write',
582 ],
583
584 /* -------- Taxonomies / Terms -------- */
585 'wp_get_taxonomies' => [
586 'name' => 'wp_get_taxonomies',
587 'description' => 'List taxonomies for a post type.',
588 'inputSchema' => [
589 'type' => 'object',
590 'properties' => [ 'post_type' => [ 'type' => 'string' ] ],
591 ],
592 'accessLevel' => 'read',
593 ],
594 'wp_get_terms' => [
595 'name' => 'wp_get_terms',
596 'description' => 'List terms of a taxonomy.',
597 'inputSchema' => [
598 'type' => 'object',
599 'properties' => [
600 'taxonomy' => [ 'type' => 'string' ],
601 'search' => [ 'type' => 'string' ],
602 'parent' => [ 'type' => 'integer' ],
603 'limit' => [ 'type' => 'integer' ],
604 ],
605 'required' => [ 'taxonomy' ],
606 ],
607 'accessLevel' => 'read',
608 ],
609 'wp_create_term' => [
610 'name' => 'wp_create_term',
611 'description' => 'Create a term.',
612 'inputSchema' => [
613 'type' => 'object',
614 'properties' => [
615 'taxonomy' => [ 'type' => 'string' ],
616 'term_name' => [ 'type' => 'string' ],
617 'slug' => [ 'type' => 'string' ],
618 'description' => [ 'type' => 'string' ],
619 'parent' => [ 'type' => 'integer' ],
620 ],
621 'required' => [ 'taxonomy', 'term_name' ],
622 ],
623 'accessLevel' => 'write',
624 ],
625 'wp_update_term' => [
626 'name' => 'wp_update_term',
627 'description' => 'Update a term.',
628 'inputSchema' => [
629 'type' => 'object',
630 'properties' => [
631 'term_id' => [ 'type' => 'integer' ],
632 'taxonomy' => [ 'type' => 'string' ],
633 'name' => [ 'type' => 'string' ],
634 'slug' => [ 'type' => 'string' ],
635 'description' => [ 'type' => 'string' ],
636 'parent' => [ 'type' => 'integer' ],
637 ],
638 'required' => [ 'term_id', 'taxonomy' ],
639 ],
640 'accessLevel' => 'write',
641 ],
642 'wp_delete_term' => [
643 'name' => 'wp_delete_term',
644 'description' => 'Delete a term.',
645 'inputSchema' => [
646 'type' => 'object',
647 'properties' => [
648 'term_id' => [ 'type' => 'integer' ],
649 'taxonomy' => [ 'type' => 'string' ],
650 ],
651 'required' => [ 'term_id', 'taxonomy' ],
652 ],
653 'accessLevel' => 'admin',
654 ],
655 'wp_get_post_terms' => [
656 'name' => 'wp_get_post_terms',
657 'description' => 'Get terms attached to a post.',
658 'inputSchema' => [
659 'type' => 'object',
660 'properties' => [
661 'ID' => [ 'type' => 'integer' ],
662 'taxonomy' => [ 'type' => 'string' ],
663 ],
664 'required' => [ 'ID' ],
665 ],
666 'accessLevel' => 'read',
667 ],
668 'wp_add_post_terms' => [
669 'name' => 'wp_add_post_terms',
670 '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.).',
671 'inputSchema' => [
672 'type' => 'object',
673 'properties' => [
674 'ID' => [ 'type' => 'integer' ],
675 'taxonomy' => [ 'type' => 'string' ],
676 'terms' => [ 'type' => 'array', 'items' => [ 'type' => 'integer' ] ],
677 'append' => [ 'type' => 'boolean' ],
678 ],
679 'required' => [ 'ID', 'terms' ],
680 ],
681 'accessLevel' => 'write',
682 ],
683
684 /* -------- Media -------- */
685 'wp_get_media' => [
686 'name' => 'wp_get_media',
687 'description' => 'List media items. Filter by uploader with `author` (user ID) or `author_name` (user slug).',
688 'inputSchema' => [
689 'type' => 'object',
690 'properties' => [
691 'search' => [ 'type' => 'string' ],
692 'author' => [ 'type' => 'integer', 'description' => 'Filter by uploader user ID.' ],
693 'author_name' => [ 'type' => 'string', 'description' => 'Filter by uploader user slug (nicename). Ignored if author is set.' ],
694 'after' => [ 'type' => 'string' ],
695 'before' => [ 'type' => 'string' ],
696 'limit' => [ 'type' => 'integer' ],
697 ],
698 ],
699 'accessLevel' => 'read',
700 ],
701 'wp_upload_media' => [
702 'name' => 'wp_upload_media',
703 '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.',
704 'inputSchema' => [
705 'type' => 'object',
706 'properties' => [
707 'url' => [
708 'type' => 'string',
709 'description' => 'URL to download the file from. Use this OR base64/filename.',
710 ],
711 'base64' => [
712 'type' => 'string',
713 'description' => 'Base64-encoded file content. Must be used together with filename.',
714 ],
715 'filename' => [
716 'type' => 'string',
717 'description' => 'Filename with extension (e.g. photo.jpg). Required when using base64.',
718 ],
719 'title' => [ 'type' => 'string' ],
720 'description' => [ 'type' => 'string' ],
721 'alt' => [ 'type' => 'string' ],
722 ],
723 ],
724 'accessLevel' => 'write',
725 ],
726 'wp_upload_request' => [
727 'name' => 'wp_upload_request',
728 '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.',
729 'inputSchema' => [
730 'type' => 'object',
731 'properties' => [
732 'filename' => [
733 'type' => 'string',
734 'description' => 'Filename with extension (e.g. photo.jpg).',
735 ],
736 'title' => [ 'type' => 'string' ],
737 'description' => [ 'type' => 'string' ],
738 'alt' => [ 'type' => 'string' ],
739 ],
740 'required' => [ 'filename' ],
741 ],
742 'accessLevel' => 'write',
743 ],
744 'wp_update_media' => [
745 'name' => 'wp_update_media',
746 'description' => 'Update attachment meta.',
747 'inputSchema' => [
748 'type' => 'object',
749 'properties' => [
750 'ID' => [ 'type' => 'integer' ],
751 'title' => [ 'type' => 'string' ],
752 'caption' => [ 'type' => 'string' ],
753 'description' => [ 'type' => 'string' ],
754 'alt' => [ 'type' => 'string' ],
755 ],
756 'required' => [ 'ID' ],
757 ],
758 'accessLevel' => 'write',
759 ],
760 'wp_delete_media' => [
761 'name' => 'wp_delete_media',
762 'description' => 'Delete/trash an attachment.',
763 'inputSchema' => [
764 'type' => 'object',
765 'properties' => [
766 'ID' => [ 'type' => 'integer' ],
767 'force' => [ 'type' => 'boolean' ],
768 ],
769 'required' => [ 'ID' ],
770 ],
771 'accessLevel' => 'admin',
772 ],
773
774 /* -------- MWAI Vision / Image -------- */
775 'mwai_vision' => [
776 'name' => 'mwai_vision',
777 'description' => 'Analyze an image via AI Engine Vision.',
778 'inputSchema' => [
779 'type' => 'object',
780 'properties' => [
781 'message' => [ 'type' => 'string' ],
782 'url' => [ 'type' => 'string' ],
783 'path' => [ 'type' => 'string' ],
784 ],
785 'required' => [ 'message' ],
786 ],
787 'accessLevel' => 'read',
788 ],
789 'mwai_image' => [
790 'name' => 'mwai_image',
791 '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 }.',
792 'inputSchema' => [
793 'type' => 'object',
794 'properties' => [
795 'message' => [ 'type' => 'string', 'description' => 'Prompt describing the desired image.' ],
796 'postId' => [ 'type' => 'integer', 'description' => 'Optional post ID to attach the image to.' ],
797 'title' => [ 'type' => 'string' ],
798 'caption' => [ 'type' => 'string' ],
799 'description' => [ 'type' => 'string' ],
800 'alt' => [ 'type' => 'string' ],
801 ],
802 'required' => [ 'message' ],
803 ],
804 'accessLevel' => 'write',
805 ],
806
807 ];
808 }
809 #endregion
810
811 #region Tool Registration
812 public function register_rest_tools( array $prev ): array {
813 $tools = $this->tools();
814
815 // All 36 core tools enabled and tested with ChatGPT.
816 // Automatic validation in mcp.php fixes problematic type definitions.
817
818 // Add category and annotations to each tool
819 foreach ( $tools as &$tool ) {
820 if ( !isset( $tool['category'] ) ) {
821 $tool['category'] = 'AI Engine (Core)';
822 }
823
824 // Add MCP tool annotations based on tool name/behavior
825 if ( !isset( $tool['annotations'] ) ) {
826 $name = $tool['name'];
827
828 // Read-only tools (safe, no modifications)
829 $is_readonly = (
830 strpos( $name, 'wp_get_' ) === 0 ||
831 strpos( $name, 'wp_list_' ) === 0 ||
832 strpos( $name, 'wp_count_' ) === 0 ||
833 $name === 'mwai_vision'
834 );
835
836 // Destructive tools (can delete/destroy data)
837 $is_destructive = (
838 strpos( $name, 'wp_delete_' ) === 0 ||
839 $name === 'wp_update_user' // Can change passwords/roles
840 );
841
842 $tool['annotations'] = [
843 'readOnlyHint' => $is_readonly,
844 'destructiveHint' => !$is_readonly && $is_destructive,
845 'openWorldHint' => false, // All operate on closed WordPress system
846 ];
847 }
848 }
849
850 $merged = array_merge( $prev, array_values( $tools ) );
851 return $merged;
852 }
853 #endregion
854
855 #region Callback
856 public function handle_call( $prev, string $tool, array $args, ?int $id ) {
857 // Security check is already done in the MCP auth layer
858 // If we reach here, the user is authorized to use MCP
859 if ( !empty( $prev ) || !isset( $this->tools()[ $tool ] ) ) {
860 return $prev;
861 }
862 return $this->dispatch( $tool, $args, $id );
863 }
864 #endregion
865
866 #region Dispatcher
867 private function dispatch( string $tool, array $a, ?int $id ): array {
868 $r = [ 'jsonrpc' => '2.0', 'id' => $id ];
869
870 // Accept common aliases for the primary record id. The post tools use the
871 // WordPress-native "ID" (matching wp_update_post() / $post->ID), while
872 // wp_set_featured_image, the comment tools, and the SEO/Woo suites use
873 // "post_id". Agents hopping between tools guess the wrong spelling and hit a
874 // bare "ID required". No tool in this suite uses two of these keys to mean
875 // two different things, so mirroring them is safe; each handler still reads
876 // its own canonical key.
877 $idAliases = [ 'ID', 'post_id', 'id' ];
878 $primaryId = null;
879 foreach ( $idAliases as $k ) {
880 if ( isset( $a[ $k ] ) && $a[ $k ] !== '' ) {
881 $primaryId = $a[ $k ];
882 break;
883 }
884 }
885 if ( $primaryId !== null ) {
886 foreach ( $idAliases as $k ) {
887 if ( !isset( $a[ $k ] ) || $a[ $k ] === '' ) {
888 $a[ $k ] = $primaryId;
889 }
890 }
891 }
892
893 switch ( $tool ) {
894
895 /* ===== Users ===== */
896 case 'wp_get_users':
897 $q = [
898 'search' => '*' . esc_attr( $a['search'] ?? '' ) . '*',
899 'role' => $a['role'] ?? '',
900 'number' => max( 1, intval( $a['limit'] ?? 10 ) ),
901 ];
902 if ( isset( $a['offset'] ) ) {
903 $q['offset'] = max( 0, intval( $a['offset'] ) );
904 }
905 if ( isset( $a['paged'] ) ) {
906 $q['paged'] = max( 1, intval( $a['paged'] ) );
907 }
908 $rows = [];
909 foreach ( get_users( $q ) as $u ) {
910 $rows[] = [
911 'ID' => $u->ID,
912 'user_login' => $u->user_login,
913 'display_name' => $u->display_name,
914 'roles' => $u->roles,
915 ];
916 }
917 $this->add_result_text( $r, wp_json_encode( $rows, JSON_PRETTY_PRINT ) );
918 break;
919
920 case 'wp_create_user':
921 $data = [
922 'user_login' => sanitize_user( $a['user_login'] ),
923 'user_email' => sanitize_email( $a['user_email'] ),
924 'user_pass' => $a['user_pass'] ?? wp_generate_password( 12, true ),
925 'display_name' => sanitize_text_field( $a['display_name'] ?? '' ),
926 'role' => sanitize_key( $a['role'] ?? get_option( 'default_role', 'subscriber' ) ),
927 ];
928 $uid = wp_insert_user( $data );
929 if ( is_wp_error( $uid ) ) {
930 $r['error'] = [ 'code' => $uid->get_error_code(), 'message' => $uid->get_error_message() ];
931 }
932 else {
933 $this->add_result_text( $r, 'User created ID ' . $uid );
934 }
935 break;
936
937 case 'wp_update_user':
938 if ( empty( $a['ID'] ) ) {
939 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
940 break;
941 }
942 $upd = [ 'ID' => intval( $a['ID'] ) ];
943 if ( !empty( $a['fields'] ) && is_array( $a['fields'] ) ) {
944 foreach ( $a['fields'] as $k => $v ) {
945 $upd[ $k ] = ( $k === 'role' ) ? sanitize_key( $v ) : sanitize_text_field( $v );
946 }
947 }
948 $u = wp_update_user( $upd );
949 if ( is_wp_error( $u ) ) {
950 $r['error'] = [ 'code' => $u->get_error_code(), 'message' => $u->get_error_message() ];
951 }
952 else {
953 $this->add_result_text( $r, 'User #' . $u . ' updated' );
954 }
955 break;
956
957 /* ===== Comments ===== */
958 case 'wp_get_comments':
959 $args = [
960 'post_id' => isset( $a['post_id'] ) ? intval( $a['post_id'] ) : '',
961 'status' => $a['status'] ?? 'approve',
962 'search' => $a['search'] ?? '',
963 'number' => max( 1, intval( $a['limit'] ?? 10 ) ),
964 ];
965 if ( isset( $a['user_id'] ) ) {
966 $args['user_id'] = intval( $a['user_id'] );
967 }
968 if ( $a['author_email'] ?? '' ) {
969 $args['author_email'] = sanitize_email( $a['author_email'] );
970 }
971 if ( isset( $a['offset'] ) ) {
972 $args['offset'] = max( 0, intval( $a['offset'] ) );
973 }
974 if ( isset( $a['paged'] ) ) {
975 $args['paged'] = max( 1, intval( $a['paged'] ) );
976 }
977 $list = [];
978 foreach ( get_comments( $args ) as $c ) {
979 $list[] = [
980 'comment_ID' => $c->comment_ID,
981 'comment_post_ID' => $c->comment_post_ID,
982 'comment_author' => $c->comment_author,
983 'comment_content' => wp_trim_words( wp_strip_all_tags( $c->comment_content ), 40 ),
984 'comment_date' => $c->comment_date,
985 'comment_approved' => $c->comment_approved,
986 ];
987 }
988 $this->add_result_text( $r, wp_json_encode( $list, JSON_PRETTY_PRINT ) );
989 break;
990
991 case 'wp_create_comment':
992 if ( empty( $a['post_id'] ) || empty( $a['comment_content'] ) ) {
993 $r['error'] = [ 'code' => -32602, 'message' => 'post_id & comment_content required' ];
994 break;
995 }
996 $ins = [
997 'comment_post_ID' => intval( $a['post_id'] ),
998 'comment_content' => $this->clean_html( $a['comment_content'] ),
999 'comment_author' => sanitize_text_field( $a['comment_author'] ?? '' ),
1000 'comment_author_email' => sanitize_email( $a['comment_author_email'] ?? '' ),
1001 'comment_author_url' => esc_url_raw( $a['comment_author_url'] ?? '' ),
1002 'comment_approved' => $a['comment_approved'] ?? 1,
1003 ];
1004 $cid = wp_insert_comment( $ins );
1005 if ( is_wp_error( $cid ) ) {
1006 /** @var WP_Error $cid */
1007 $r['error'] = [ 'code' => $cid->get_error_code(), 'message' => $cid->get_error_message() ];
1008 }
1009 else {
1010 $this->add_result_text( $r, 'Comment created ID ' . $cid );
1011 }
1012 break;
1013
1014 case 'wp_update_comment':
1015 if ( empty( $a['comment_ID'] ) ) {
1016 $r['error'] = [ 'code' => -32602, 'message' => 'comment_ID required' ];
1017 break;
1018 }
1019 $c = [ 'comment_ID' => intval( $a['comment_ID'] ) ];
1020 if ( !empty( $a['fields'] ) && is_array( $a['fields'] ) ) {
1021 foreach ( $a['fields'] as $k => $v ) {
1022 $c[ $k ] = ( $k === 'comment_content' ) ? $this->clean_html( $v ) : sanitize_text_field( $v );
1023 }
1024 }
1025 $cid = wp_update_comment( $c, true );
1026 if ( is_wp_error( $cid ) ) {
1027 $r['error'] = [ 'code' => $cid->get_error_code(), 'message' => $cid->get_error_message() ];
1028 }
1029 else {
1030 $this->add_result_text( $r, 'Comment #' . $cid . ' updated' );
1031 }
1032 break;
1033
1034 case 'wp_delete_comment':
1035 if ( empty( $a['comment_ID'] ) ) {
1036 $r['error'] = [ 'code' => -32602, 'message' => 'comment_ID required' ];
1037 break;
1038 }
1039 $done = wp_delete_comment( intval( $a['comment_ID'] ), !empty( $a['force'] ) );
1040 if ( $done ) {
1041 $this->add_result_text( $r, 'Comment #' . $a['comment_ID'] . ' deleted' );
1042 }
1043 else {
1044 $r['error'] = [ 'code' => -32603, 'message' => 'Deletion failed' ];
1045 }
1046 break;
1047
1048 /* ===== Options ===== */
1049 case 'wp_get_option':
1050 $opt_key = sanitize_key( $a['key'] );
1051 if ( !empty( $a['raw'] ) ) {
1052 // Read straight from the DB so neither the object cache nor an
1053 // option_* filter can mask the stored value. Mirrors what `wp-cli
1054 // option get` returns under CLI (where front-end filters aren't loaded).
1055 global $wpdb;
1056 $stored = $wpdb->get_var( $wpdb->prepare(
1057 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
1058 $opt_key
1059 ) );
1060 $val = is_null( $stored ) ? false : maybe_unserialize( $stored );
1061 }
1062 else {
1063 $val = get_option( $opt_key );
1064 }
1065 $this->add_result_text( $r, wp_json_encode( $val, JSON_PRETTY_PRINT ) );
1066 break;
1067
1068 case 'wp_update_option':
1069 $value = $a['value'];
1070 // MCP clients commonly send array/object option values as a JSON string.
1071 // Decode them back to native PHP arrays before writing: storing the raw
1072 // JSON string for an array option (e.g. sticky_posts) corrupts it and can
1073 // fatal hooks that expect an array (Polylang's sync_sticky_posts runs
1074 // array_diff on it). Scalars and plain strings are left untouched.
1075 if ( is_string( $value ) && isset( $value[0] ) && ( $value[0] === '[' || $value[0] === '{' ) ) {
1076 $decoded = json_decode( $value, true );
1077 if ( json_last_error() === JSON_ERROR_NONE && is_array( $decoded ) ) {
1078 $value = $decoded;
1079 }
1080 }
1081 $set = update_option( sanitize_key( $a['key'] ), $value, 'yes' );
1082 if ( $set ) {
1083 $this->add_result_text( $r, 'Option "' . $a['key'] . '" updated' );
1084 }
1085 else {
1086 $r['error'] = [ 'code' => -32603, 'message' => 'Update failed' ];
1087 }
1088 break;
1089
1090 /* ===== Counts ===== */
1091 case 'wp_count_posts':
1092 $pt = sanitize_key( $a['post_type'] ?? 'post' );
1093 $obj = wp_count_posts( $pt );
1094 $this->add_result_text( $r, wp_json_encode( $obj, JSON_PRETTY_PRINT ) );
1095 break;
1096
1097 case 'wp_count_terms':
1098 $tax = sanitize_key( $a['taxonomy'] );
1099 $total = wp_count_terms( $tax, [ 'hide_empty' => false ] );
1100 if ( is_wp_error( $total ) ) {
1101 $r['error'] = [ 'code' => $total->get_error_code(), 'message' => $total->get_error_message() ];
1102 }
1103 else {
1104 $this->add_result_text( $r, (string) $total );
1105 }
1106 break;
1107
1108 case 'wp_count_media':
1109 $args = [ 'post_type' => 'attachment', 'post_status' => 'inherit', 'fields' => 'ids' ];
1110 $d = [];
1111 if ( $a['after'] ?? '' ) {
1112 $d['after'] = $a['after'];
1113 }
1114 if ( $a['before'] ?? '' ) {
1115 $d['before'] = $a['before'];
1116 }
1117 if ( $d ) {
1118 $args['date_query'] = [ $d ];
1119 }
1120 $total = count( get_posts( $args ) );
1121 $this->add_result_text( $r, (string) $total );
1122 break;
1123
1124 /* ===== Post-types ===== */
1125 case 'wp_get_post_types':
1126 $out = [];
1127 foreach ( get_post_types( [ 'public' => true ], 'objects' ) as $pt ) {
1128 $out[] = [ 'key' => $pt->name, 'label' => $pt->label ];
1129 }
1130 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
1131 break;
1132
1133 /* ===== Plugins ===== */
1134 case 'wp_list_plugins':
1135 if ( !function_exists( 'get_plugins' ) ) {
1136 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1137 }
1138 $search = sanitize_text_field( $a['search'] ?? '' );
1139 $out = [];
1140 foreach ( get_plugins() as $p ) {
1141 if ( !$search || stripos( $p['Name'], $search ) !== false ) {
1142 $out[] = [ 'Name' => $p['Name'], 'Version' => $p['Version'] ];
1143 }
1144 }
1145 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
1146 break;
1147
1148 /* ===== Posts: list ===== */
1149 case 'wp_get_posts':
1150 $q = [
1151 'post_type' => sanitize_key( $a['post_type'] ?? 'post' ),
1152 'post_status' => sanitize_key( $a['post_status'] ?? 'publish' ),
1153 's' => sanitize_text_field( $a['search'] ?? '' ),
1154 'posts_per_page' => max( 1, intval( $a['limit'] ?? 10 ) ),
1155 ];
1156 if ( isset( $a['offset'] ) ) {
1157 $q['offset'] = max( 0, intval( $a['offset'] ) );
1158 }
1159 if ( isset( $a['paged'] ) ) {
1160 $q['paged'] = max( 1, intval( $a['paged'] ) );
1161 }
1162 if ( isset( $a['author'] ) ) {
1163 $q['author'] = intval( $a['author'] );
1164 }
1165 elseif ( $a['author_name'] ?? '' ) {
1166 $q['author_name'] = sanitize_title( $a['author_name'] );
1167 }
1168 if ( !empty( $a['author__not_in'] ) && is_array( $a['author__not_in'] ) ) {
1169 $q['author__not_in'] = array_map( 'intval', $a['author__not_in'] );
1170 }
1171 $date = [];
1172 if ( $a['after'] ?? '' ) {
1173 $date['after'] = $a['after'];
1174 }
1175 if ( $a['before'] ?? '' ) {
1176 $date['before'] = $a['before'];
1177 }
1178 if ( $date ) {
1179 $q['date_query'] = [ $date ];
1180 }
1181 $rows = [];
1182 foreach ( get_posts( $q ) as $p ) {
1183 $rows[] = [
1184 'ID' => $p->ID,
1185 'post_title' => $p->post_title,
1186 'post_status' => $p->post_status,
1187 'post_excerpt' => $this->post_excerpt( $p ),
1188 'permalink' => get_permalink( $p ),
1189 ];
1190 }
1191 $this->add_result_text( $r, wp_json_encode( $rows, JSON_PRETTY_PRINT ) );
1192 break;
1193
1194 /* ===== Posts: single ===== */
1195 case 'wp_get_post':
1196 if ( empty( $a['ID'] ) ) {
1197 $r['error'] = [ 'code' => -32602, 'message' => 'Post ID required (pass "ID", e.g. {"ID": 123}; "post_id" is also accepted).' ];
1198 break;
1199 }
1200 $p = get_post( intval( $a['ID'] ) );
1201 if ( !$p ) {
1202 $r['error'] = [ 'code' => -32602, 'message' => 'Post not found' ];
1203 break;
1204 }
1205 $out = [
1206 'ID' => $p->ID,
1207 'post_title' => $p->post_title,
1208 'post_status' => $p->post_status,
1209 'post_content' => ( ( $a['content_format'] ?? 'full' ) === 'prose' )
1210 ? $this->prose_content( $p->post_content )
1211 : $this->clean_html( $p->post_content ),
1212 'post_excerpt' => $this->post_excerpt( $p ),
1213 'permalink' => get_permalink( $p ),
1214 'post_date' => $p->post_date,
1215 'post_modified' => $p->post_modified,
1216 ];
1217 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
1218 break;
1219
1220 /* ===== Posts: snapshot ===== */
1221 case 'wp_get_post_snapshot':
1222 if ( empty( $a['ID'] ) ) {
1223 $r['error'] = [ 'code' => -32602, 'message' => 'Post ID required (pass "ID", e.g. {"ID": 123}; "post_id" is also accepted).' ];
1224 break;
1225 }
1226
1227 $post_id = intval( $a['ID'] );
1228 $p = get_post( $post_id );
1229
1230 if ( !$p ) {
1231 $r['error'] = [ 'code' => -32602, 'message' => 'Post not found' ];
1232 break;
1233 }
1234
1235 $include = $a['include'] ?? [ 'meta', 'terms', 'thumbnail', 'author' ];
1236 $exclude = $a['exclude'] ?? [];
1237
1238 // Handle JSON strings (some MCP clients send arrays as JSON strings)
1239 if ( is_string( $include ) ) {
1240 $include = json_decode( $include, true ) ?? [];
1241 }
1242 if ( is_string( $exclude ) ) {
1243 $exclude = json_decode( $exclude, true ) ?? [];
1244 }
1245
1246 $snapshot = [
1247 'post' => [
1248 'ID' => $p->ID,
1249 'post_title' => $p->post_title,
1250 'post_type' => $p->post_type,
1251 'post_status' => $p->post_status,
1252 'post_excerpt' => $this->post_excerpt( $p ),
1253 'post_name' => $p->post_name,
1254 'permalink' => get_permalink( $p ),
1255 'post_date' => $p->post_date,
1256 'post_modified' => $p->post_modified,
1257 ],
1258 ];
1259
1260 // Include content unless excluded (useful for posts with huge content)
1261 if ( !in_array( 'content', $exclude ) ) {
1262 $snapshot['post']['post_content'] = ( ( $a['content_format'] ?? 'full' ) === 'prose' )
1263 ? $this->prose_content( $p->post_content )
1264 : $this->clean_html( $p->post_content );
1265 }
1266
1267 // Include all post meta
1268 if ( in_array( 'meta', $include ) ) {
1269 $snapshot['meta'] = [];
1270 $all_meta = get_post_meta( $post_id );
1271 foreach ( $all_meta as $key => $value ) {
1272 if ( is_array( $value ) && count( $value ) === 1 ) {
1273 $snapshot['meta'][ $key ] = maybe_unserialize( $value[0] );
1274 }
1275 else {
1276 $snapshot['meta'][ $key ] = array_map( 'maybe_unserialize', $value );
1277 }
1278 }
1279 }
1280
1281 // Include all taxonomies and their terms
1282 if ( in_array( 'terms', $include ) ) {
1283 $snapshot['terms'] = [];
1284 $taxonomies = get_object_taxonomies( $p->post_type );
1285 foreach ( $taxonomies as $taxonomy ) {
1286 $terms = wp_get_post_terms( $post_id, $taxonomy, [ 'fields' => 'all' ] );
1287 if ( !is_wp_error( $terms ) && !empty( $terms ) ) {
1288 $snapshot['terms'][ $taxonomy ] = array_map( function ( $t ) {
1289 return [
1290 'term_id' => $t->term_id,
1291 'name' => $t->name,
1292 'slug' => $t->slug,
1293 ];
1294 }, $terms );
1295 }
1296 }
1297 }
1298
1299 // Include featured image
1300 if ( in_array( 'thumbnail', $include ) ) {
1301 $thumb_id = get_post_thumbnail_id( $post_id );
1302 if ( $thumb_id ) {
1303 $snapshot['thumbnail'] = [
1304 'ID' => $thumb_id,
1305 'url' => wp_get_attachment_url( $thumb_id ),
1306 'alt' => get_post_meta( $thumb_id, '_wp_attachment_image_alt', true ),
1307 ];
1308 }
1309 }
1310
1311 // Include author
1312 if ( in_array( 'author', $include ) ) {
1313 $author = get_userdata( $p->post_author );
1314 if ( $author ) {
1315 $snapshot['author'] = [
1316 'ID' => $author->ID,
1317 'display_name' => $author->display_name,
1318 'user_login' => $author->user_login,
1319 ];
1320 }
1321 }
1322
1323 $this->add_result_text( $r, wp_json_encode( $snapshot, JSON_PRETTY_PRINT ) );
1324 break;
1325
1326 /* ===== Posts: create ===== */
1327 case 'wp_create_post':
1328 if ( empty( $a['post_title'] ) ) {
1329 $r['error'] = [ 'code' => -32602, 'message' => 'post_title required' ];
1330 break;
1331 }
1332 $ins = [
1333 'post_title' => sanitize_text_field( $a['post_title'] ),
1334 'post_status' => sanitize_key( $a['post_status'] ?? 'draft' ),
1335 'post_type' => sanitize_key( $a['post_type'] ?? 'post' ),
1336 ];
1337 if ( $a['post_content'] ?? '' ) {
1338 $ins['post_content'] = $this->prepare_new_content( $a['post_content'] );
1339 }
1340 if ( $a['post_excerpt'] ?? '' ) {
1341 $ins['post_excerpt'] = $this->clean_html( $a['post_excerpt'] );
1342 }
1343 if ( $a['post_name'] ?? '' ) {
1344 $ins['post_name'] = sanitize_title( $a['post_name'] );
1345 }
1346
1347 // Handle JSON strings for meta_input (some MCP clients send objects as JSON strings)
1348 $meta_input = $a['meta_input'] ?? [];
1349 if ( is_string( $meta_input ) ) {
1350 $meta_input = json_decode( $meta_input, true ) ?? [];
1351 }
1352 if ( !empty( $meta_input ) && is_array( $meta_input ) ) {
1353 $ins['meta_input'] = $meta_input;
1354 }
1355
1356 $new = wp_insert_post( wp_slash( $ins ), true );
1357 if ( is_wp_error( $new ) ) {
1358 $r['error'] = [ 'code' => $new->get_error_code(), 'message' => $new->get_error_message() ];
1359 }
1360 else {
1361 if ( empty( $ins['meta_input'] ) && !empty( $meta_input ) && is_array( $meta_input ) ) {
1362 foreach ( $meta_input as $k => $v ) {
1363 update_post_meta( $new, sanitize_key( $k ), maybe_serialize( $v ) );
1364 }
1365 }
1366 $this->bust_post_cache( (int) $new, [ 'tool' => 'wp_create_post' ] );
1367 $this->add_result_text( $r, 'Post created ID ' . $new );
1368 }
1369 break;
1370
1371 /* ===== Posts: update ===== */
1372 case 'wp_update_post':
1373 if ( empty( $a['ID'] ) ) {
1374 $r['error'] = [ 'code' => -32602, 'message' => 'Post ID required (pass "ID", e.g. {"ID": 123}; "post_id" is also accepted).' ];
1375 break;
1376 }
1377 $post_id = intval( $a['ID'] );
1378 $c = [ 'ID' => $post_id ];
1379
1380 // Handle JSON strings (some MCP clients send objects as JSON strings)
1381 $fields_raw = $a['fields'] ?? null;
1382 $fields = $fields_raw;
1383 if ( is_string( $fields ) ) {
1384 $fields = json_decode( $fields, true );
1385 // Detect truncated/malformed JSON
1386 if ( $fields === null && strlen( $fields_raw ) > 0 ) {
1387 $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' ];
1388 break;
1389 }
1390 }
1391 $fields = $fields ?? [];
1392 if ( !is_array( $fields ) ) {
1393 $fields = [];
1394 }
1395
1396 // Convenience: also accept post fields passed at the top level instead of
1397 // nested in "fields". Agents routinely send { ID, post_title } directly and
1398 // would otherwise get a misleading "no fields provided" error. Nested
1399 // values win on conflict.
1400 $topLevelFields = [ 'post_title', 'post_content', 'post_status', 'post_name',
1401 'post_excerpt', 'post_category', 'post_type', 'post_author', 'post_parent',
1402 'post_date', 'menu_order', 'comment_status', 'ping_status', 'page_template' ];
1403 foreach ( $topLevelFields as $fk ) {
1404 if ( array_key_exists( $fk, $a ) && !array_key_exists( $fk, $fields ) ) {
1405 $fields[ $fk ] = $a[ $fk ];
1406 }
1407 }
1408
1409 // Track what we're trying to update for verification
1410 $content_to_verify = null;
1411 if ( !empty( $fields ) && is_array( $fields ) ) {
1412 foreach ( $fields as $k => $v ) {
1413 $c[ $k ] = in_array( $k, [ 'post_content', 'post_excerpt' ], true ) ? $this->clean_html( $v ) : sanitize_text_field( $v );
1414 }
1415 if ( isset( $c['post_content'] ) ) {
1416 $content_to_verify = $c['post_content'];
1417 }
1418 }
1419
1420 // Handle schedule_for convenience parameter
1421 if ( !empty( $a['schedule_for'] ) ) {
1422 $schedule_date = sanitize_text_field( $a['schedule_for'] );
1423 $c['post_status'] = 'future';
1424 $c['post_date'] = $schedule_date;
1425 $c['post_date_gmt'] = get_gmt_from_date( $schedule_date );
1426 $c['edit_date'] = true; // Required for WordPress to respect date changes
1427 }
1428
1429 // Handle JSON strings for meta_input
1430 $meta_raw = $a['meta_input'] ?? null;
1431 $meta_input = $meta_raw;
1432 if ( is_string( $meta_input ) ) {
1433 $meta_input = json_decode( $meta_input, true );
1434 if ( $meta_input === null && strlen( $meta_raw ) > 0 ) {
1435 $r['error'] = [ 'code' => -32602, 'message' => 'meta_input parameter is invalid JSON (possibly truncated).' ];
1436 break;
1437 }
1438 }
1439 $meta_input = $meta_input ?? [];
1440 $has_meta = !empty( $meta_input ) && is_array( $meta_input );
1441 $has_fields = count( $c ) > 1;
1442
1443 // Error if nothing to update
1444 if ( !$has_fields && !$has_meta ) {
1445 $hint = '';
1446 if ( isset( $a['fields'] ) || isset( $a['meta_input'] ) ) {
1447 $hint = ' (parameters were provided but parsed as empty - check for malformed JSON)';
1448 }
1449 $r['error'] = [ 'code' => -32602, 'message' => 'No fields or meta_input provided to update. Pass post fields inside a "fields" object (or at the top level), e.g. {"ID": 123, "fields": {"post_title": "..."}}, and/or "meta_input" for custom fields.' . $hint ];
1450 break;
1451 }
1452
1453 // Detect trash / untrash transitions and route through wp_trash_post() /
1454 // wp_untrash_post() so the proper hooks fire (ACF cleanup, search-index purges,
1455 // SEO plugins, etc.). A bare wp_update_post( ['post_status' => 'trash'] ) just
1456 // flips the status field and skips all of that.
1457 $u = $post_id;
1458 if ( isset( $c['post_status'] ) ) {
1459 $current = get_post( $post_id );
1460 $current_status = $current ? $current->post_status : null;
1461 $target_status = $c['post_status'];
1462
1463 if ( $target_status === 'trash' && $current_status !== 'trash' ) {
1464 $trashed = wp_trash_post( $post_id );
1465 if ( !$trashed ) {
1466 $r['error'] = [ 'code' => -32603, 'message' => 'wp_trash_post failed' ];
1467 break;
1468 }
1469 unset( $c['post_status'] );
1470 $has_fields = count( $c ) > 1;
1471 }
1472 elseif ( $current_status === 'trash' && $target_status !== 'trash' ) {
1473 $untrashed = wp_untrash_post( $post_id );
1474 if ( !$untrashed ) {
1475 $r['error'] = [ 'code' => -32603, 'message' => 'wp_untrash_post failed' ];
1476 break;
1477 }
1478 // Leave post_status in $c: wp_untrash_post restores to a previous status, and
1479 // a subsequent wp_update_post() will set the explicit one the caller asked for.
1480 }
1481 }
1482
1483 // Update post fields if any
1484 if ( $has_fields ) {
1485 $u = wp_update_post( wp_slash( $c ), true );
1486 if ( is_wp_error( $u ) ) {
1487 $r['error'] = [ 'code' => $u->get_error_code(), 'message' => $u->get_error_message() ];
1488 break;
1489 }
1490 }
1491
1492 // Update meta if any
1493 if ( $has_meta ) {
1494 foreach ( $meta_input as $k => $v ) {
1495 update_post_meta( $u, sanitize_key( $k ), maybe_serialize( $v ) );
1496 }
1497 }
1498
1499 $this->bust_post_cache( (int) $u, [ 'tool' => 'wp_update_post' ] );
1500
1501 // Verify the update actually took effect
1502 $updated_post = get_post( $u );
1503 $result = [
1504 'post_id' => $u,
1505 'post_modified' => $updated_post->post_modified,
1506 ];
1507
1508 // Verify content was saved correctly if we tried to update it
1509 if ( $content_to_verify !== null ) {
1510 $saved_content = $updated_post->post_content;
1511 $result['content_length'] = strlen( $saved_content );
1512 if ( $saved_content !== $content_to_verify ) {
1513 $result['warning'] = 'Content differs from input (sanitization applied or save failed)';
1514 $result['expected_length'] = strlen( $content_to_verify );
1515 }
1516 }
1517
1518 if ( !empty( $a['schedule_for'] ) ) {
1519 $result['scheduled_for'] = $a['schedule_for'];
1520 }
1521
1522 $this->add_result_text( $r, wp_json_encode( $result, JSON_PRETTY_PRINT ) );
1523 break;
1524
1525 /* ===== Posts: delete ===== */
1526 case 'wp_delete_post':
1527 if ( empty( $a['ID'] ) ) {
1528 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
1529 break;
1530 }
1531 $delete_id = intval( $a['ID'] );
1532 $del = wp_delete_post( $delete_id, !empty( $a['force'] ) );
1533 if ( $del ) {
1534 $this->bust_post_cache( $delete_id, [ 'tool' => 'wp_delete_post' ] );
1535 $this->add_result_text( $r, 'Post #' . $a['ID'] . ' deleted' );
1536 }
1537 else {
1538 $r['error'] = [ 'code' => -32603, 'message' => 'Deletion failed' ];
1539 }
1540 break;
1541
1542 /* ===== Posts: alter (search/replace) ===== */
1543 case 'wp_alter_post':
1544 if ( empty( $a['ID'] ) || empty( $a['field'] ) || !isset( $a['search'] ) || !isset( $a['replace'] ) ) {
1545 $r['error'] = [ 'code' => -32602, 'message' => 'ID, field, search, and replace required' ];
1546 break;
1547 }
1548 $post_id = intval( $a['ID'] );
1549 $field = sanitize_key( $a['field'] );
1550 $search = $a['search'];
1551 $replace = $a['replace'];
1552 $is_regex = !empty( $a['regex'] );
1553 $flags = isset( $a['flags'] ) && is_string( $a['flags'] ) ? $a['flags'] : '';
1554
1555 // Validate field
1556 $allowed_fields = [ 'post_content', 'post_excerpt', 'post_title' ];
1557 if ( !in_array( $field, $allowed_fields, true ) ) {
1558 $r['error'] = [ 'code' => -32602, 'message' => 'Field must be: post_content, post_excerpt, or post_title' ];
1559 break;
1560 }
1561
1562 $post = get_post( $post_id );
1563 if ( !$post ) {
1564 $r['error'] = [ 'code' => -32602, 'message' => 'Post not found' ];
1565 break;
1566 }
1567
1568 $content = $post->$field;
1569 $count = 0;
1570
1571 if ( $is_regex ) {
1572 list( $compiled, $regex_err ) = $this->compile_alter_regex( $search, $flags );
1573 if ( $regex_err !== null ) {
1574 $r['error'] = [ 'code' => -32602, 'message' => $regex_err ];
1575 break;
1576 }
1577 $new_content = preg_replace( $compiled, $replace, $content, -1, $count );
1578 if ( $new_content === null ) {
1579 $msg = function_exists( 'preg_last_error_msg' ) ? preg_last_error_msg() : 'PCRE error code ' . preg_last_error();
1580 $r['error'] = [ 'code' => -32603, 'message' => 'Regex replacement failed: ' . $msg ];
1581 break;
1582 }
1583 }
1584 else {
1585 $new_content = str_replace( $search, $replace, $content, $count );
1586 }
1587
1588 if ( $count === 0 ) {
1589 $this->add_result_text( $r, 'No occurrences found; post unchanged.' );
1590 break;
1591 }
1592
1593 // wp_update_post() runs wp_unslash() internally, which would strip the
1594 // backslash from Unicode escapes like \u003c in block JSON (Rank Math
1595 // FAQ, etc.) and silently corrupt the post. Pre-slash to compensate.
1596 $update = wp_update_post( wp_slash( [ 'ID' => $post_id, $field => $new_content ] ), true );
1597 if ( is_wp_error( $update ) ) {
1598 $r['error'] = [ 'code' => $update->get_error_code(), 'message' => $update->get_error_message() ];
1599 break;
1600 }
1601
1602 $this->bust_post_cache( $post_id, [ 'tool' => 'wp_alter_post' ] );
1603 $this->add_result_text( $r, $count . ' replacement' . ( $count === 1 ? '' : 's' ) . ' applied to ' . $field . ' of post #' . $post_id );
1604 break;
1605
1606 /* ===== Post-meta ===== */
1607 case 'wp_get_post_meta':
1608 if ( empty( $a['ID'] ) ) {
1609 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
1610 break;
1611 }
1612 $pid = intval( $a['ID'] );
1613 $out = ( $a['key'] ?? '' ) ? get_post_meta( $pid, sanitize_key( $a['key'] ), true ) : get_post_meta( $pid );
1614 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
1615 break;
1616
1617 case 'wp_update_post_meta':
1618 if ( empty( $a['ID'] ) ) {
1619 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
1620 break;
1621 }
1622 $pid = intval( $a['ID'] );
1623
1624 // Handle JSON strings for meta (some MCP clients send objects as JSON strings)
1625 $meta = $a['meta'] ?? null;
1626 if ( is_string( $meta ) ) {
1627 $meta = json_decode( $meta, true );
1628 }
1629
1630 if ( !empty( $meta ) && is_array( $meta ) ) {
1631 foreach ( $meta as $k => $v ) {
1632 update_post_meta( $pid, sanitize_key( $k ), maybe_serialize( $v ) );
1633 }
1634 }
1635 elseif ( isset( $a['key'], $a['value'] ) ) {
1636 update_post_meta( $pid, sanitize_key( $a['key'] ), maybe_serialize( $a['value'] ) );
1637 }
1638 else {
1639 $r['error'] = [ 'code' => -32602, 'message' => 'meta array or key/value required' ];
1640 break;
1641 }
1642 $this->add_result_text( $r, 'Meta updated for post #' . $pid );
1643 break;
1644
1645 case 'wp_delete_post_meta':
1646 if ( empty( $a['ID'] ) || empty( $a['key'] ) ) {
1647 $r['error'] = [ 'code' => -32602, 'message' => 'ID & key required' ];
1648 break;
1649 }
1650 $pid = intval( $a['ID'] );
1651 $key = sanitize_key( $a['key'] );
1652 $done = isset( $a['value'] ) ? delete_post_meta( $pid, $key, maybe_serialize( $a['value'] ) ) : delete_post_meta( $pid, $key );
1653 if ( $done ) {
1654 $this->add_result_text( $r, 'Meta deleted on post #' . $pid );
1655 }
1656 else {
1657 $r['error'] = [ 'code' => -32603, 'message' => 'Deletion failed' ];
1658 }
1659 break;
1660
1661 /* ===== Featured image ===== */
1662 case 'wp_set_featured_image':
1663 if ( empty( $a['post_id'] ) ) {
1664 $r['error'] = [ 'code' => -32602, 'message' => 'post_id required' ];
1665 break;
1666 }
1667 $post_id = intval( $a['post_id'] );
1668 $media_id = isset( $a['media_id'] ) ? intval( $a['media_id'] ) : 0;
1669 if ( $media_id ) {
1670 $done = set_post_thumbnail( $post_id, $media_id );
1671 if ( $done ) {
1672 $this->add_result_text( $r, 'Featured image set on post #' . $post_id );
1673 }
1674 else {
1675 $r['error'] = [ 'code' => -32603, 'message' => 'Failed to set thumbnail' ];
1676 }
1677 }
1678 else {
1679 delete_post_thumbnail( $post_id );
1680 $this->add_result_text( $r, 'Featured image removed from post #' . $post_id );
1681 }
1682 break;
1683
1684 /* ===== Taxonomies ===== */
1685 case 'wp_get_taxonomies':
1686 $pt = sanitize_key( $a['post_type'] ?? 'post' );
1687 $out = [];
1688 foreach ( get_object_taxonomies( $pt, 'objects' ) as $t ) {
1689 $out[] = [ 'key' => $t->name, 'label' => $t->label ];
1690 }
1691 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
1692 break;
1693
1694 case 'wp_get_terms':
1695 $tax = sanitize_key( $a['taxonomy'] );
1696 $args = [
1697 'taxonomy' => $tax,
1698 'hide_empty' => false,
1699 'number' => intval( $a['limit'] ?? 0 ),
1700 'search' => $a['search'] ?? '',
1701 ];
1702 if ( isset( $a['parent'] ) ) {
1703 $args['parent'] = intval( $a['parent'] );
1704 }
1705 $out = [];
1706 foreach ( get_terms( $args ) as $t ) {
1707 $out[] = [ 'term_id' => $t->term_id, 'name' => $t->name, 'slug' => $t->slug, 'count' => $t->count ];
1708 }
1709 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
1710 break;
1711
1712 case 'wp_create_term':
1713 if ( empty( $a['term_name'] ) ) {
1714 $r['error'] = [ 'code' => -32602, 'message' => 'term_name required' ];
1715 break;
1716 }
1717 $tax = sanitize_key( $a['taxonomy'] );
1718 $args = [];
1719 if ( $a['slug'] ?? '' ) {
1720 $args['slug'] = sanitize_title( $a['slug'] );
1721 }
1722 if ( $a['description'] ?? '' ) {
1723 $args['description'] = sanitize_text_field( $a['description'] );
1724 }
1725 if ( isset( $a['parent'] ) ) {
1726 $args['parent'] = intval( $a['parent'] );
1727 }
1728 $term = wp_insert_term( sanitize_text_field( $a['term_name'] ), $tax, $args );
1729 if ( is_wp_error( $term ) ) {
1730 $r['error'] = [ 'code' => $term->get_error_code(), 'message' => $term->get_error_message() ];
1731 }
1732 else {
1733 $this->add_result_text( $r, 'Term ' . $term['term_id'] . ' created' );
1734 }
1735 break;
1736
1737 case 'wp_update_term':
1738 $tid = intval( $a['term_id'] ?? 0 );
1739 if ( !$tid ) {
1740 $r['error'] = [ 'code' => -32602, 'message' => 'term_id required' ];
1741 break;
1742 }
1743 $tax = sanitize_key( $a['taxonomy'] );
1744 $uargs = [];
1745 foreach ( [ 'name', 'slug', 'description', 'parent' ] as $f ) {
1746 if ( isset( $a[$f] ) ) {
1747 $uargs[$f] = $a[$f];
1748 }
1749 }
1750 $t = wp_update_term( $tid, $tax, $uargs );
1751 if ( is_wp_error( $t ) ) {
1752 $r['error'] = [ 'code' => $t->get_error_code(), 'message' => $t->get_error_message() ];
1753 }
1754 else {
1755 $this->add_result_text( $r, 'Term ' . $tid . ' updated' );
1756 }
1757 break;
1758
1759 case 'wp_delete_term':
1760 $tid = intval( $a['term_id'] ?? 0 );
1761 if ( !$tid ) {
1762 $r['error'] = [ 'code' => -32602, 'message' => 'term_id required' ];
1763 break;
1764 }
1765 $tax = sanitize_key( $a['taxonomy'] );
1766 $d = wp_delete_term( $tid, $tax );
1767 if ( $d ) {
1768 $this->add_result_text( $r, 'Term ' . $tid . ' deleted' );
1769 }
1770 else {
1771 $r['error'] = [ 'code' => -32603, 'message' => 'Deletion failed' ];
1772 }
1773 break;
1774
1775 case 'wp_get_post_terms':
1776 if ( empty( $a['ID'] ) ) {
1777 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
1778 break;
1779 }
1780 $tax = sanitize_key( $a['taxonomy'] ?? 'category' );
1781 $out = [];
1782 foreach ( wp_get_post_terms( intval( $a['ID'] ), $tax, [ 'fields' => 'all' ] ) as $t ) {
1783 $out[] = [ 'term_id' => $t->term_id, 'name' => $t->name ];
1784 }
1785 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
1786 break;
1787
1788 case 'wp_add_post_terms':
1789 if ( empty( $a['ID'] ) || empty( $a['terms'] ) ) {
1790 $r['error'] = [ 'code' => -32602, 'message' => 'ID & terms required' ];
1791 break;
1792 }
1793 $terms = $a['terms'];
1794 // Handle JSON strings (some MCP clients send arrays as JSON strings)
1795 if ( is_string( $terms ) ) {
1796 $terms = json_decode( $terms, true ) ?? [];
1797 }
1798 $tax = sanitize_key( $a['taxonomy'] ?? 'category' );
1799 $append = !isset( $a['append'] ) || $a['append'];
1800 $set = wp_set_post_terms( intval( $a['ID'] ), $terms, $tax, $append );
1801 if ( is_wp_error( $set ) ) {
1802 $r['error'] = [ 'code' => $set->get_error_code(), 'message' => $set->get_error_message() ];
1803 }
1804 else {
1805 $this->add_result_text( $r, 'Terms set for post #' . $a['ID'] );
1806 }
1807 break;
1808
1809 /* ===== Media: list ===== */
1810 case 'wp_get_media':
1811 $q = [
1812 'post_type' => 'attachment',
1813 's' => $a['search'] ?? '',
1814 'posts_per_page' => max( 1, intval( $a['limit'] ?? 10 ) ),
1815 'post_status' => 'inherit',
1816 ];
1817 if ( isset( $a['author'] ) ) {
1818 $q['author'] = intval( $a['author'] );
1819 }
1820 elseif ( $a['author_name'] ?? '' ) {
1821 $q['author_name'] = sanitize_title( $a['author_name'] );
1822 }
1823 $d = [];
1824 if ( $a['after'] ?? '' ) {
1825 $d['after'] = $a['after'];
1826 }
1827 if ( $a['before'] ?? '' ) {
1828 $d['before'] = $a['before'];
1829 }
1830 if ( $d ) {
1831 $q['date_query'] = [ $d ];
1832 }
1833 $list = [];
1834 foreach ( get_posts( $q ) as $m ) {
1835 $list[] = [ 'ID' => $m->ID, 'title' => $m->post_title, 'url' => wp_get_attachment_url( $m->ID ) ];
1836 }
1837 $this->add_result_text( $r, wp_json_encode( $list, JSON_PRETTY_PRINT ) );
1838 break;
1839
1840 /* ===== Media: upload ===== */
1841 case 'wp_upload_media':
1842 $has_url = !empty( $a['url'] );
1843 $has_base64 = !empty( $a['base64'] ) && !empty( $a['filename'] );
1844 if ( !$has_url && !$has_base64 ) {
1845 $r['error'] = [ 'code' => -32602, 'message' => 'Provide either url, or base64 + filename.' ];
1846 break;
1847 }
1848 try {
1849 require_once ABSPATH . 'wp-admin/includes/file.php';
1850 require_once ABSPATH . 'wp-admin/includes/media.php';
1851 require_once ABSPATH . 'wp-admin/includes/image.php';
1852
1853 if ( $has_url ) {
1854 $tmp = download_url( $a['url'] );
1855 if ( is_wp_error( $tmp ) ) {
1856 throw new Exception( $tmp->get_error_message(), $tmp->get_error_code() );
1857 }
1858 $file = [ 'name' => basename( parse_url( $a['url'], PHP_URL_PATH ) ), 'tmp_name' => $tmp ];
1859 }
1860 else {
1861 $decoded = base64_decode( $a['base64'], true );
1862 if ( $decoded === false ) {
1863 throw new Exception( 'Invalid base64 data.' );
1864 }
1865 $tmp = wp_tempnam( $a['filename'] );
1866 file_put_contents( $tmp, $decoded );
1867 $file = [ 'name' => sanitize_file_name( $a['filename'] ), 'tmp_name' => $tmp ];
1868 }
1869
1870 $id = media_handle_sideload( $file, 0, $a['description'] ?? '' );
1871 @unlink( $tmp );
1872 if ( is_wp_error( $id ) ) {
1873 throw new Exception( $id->get_error_message(), $id->get_error_code() );
1874 }
1875 if ( $a['title'] ?? '' ) {
1876 wp_update_post( wp_slash( [ 'ID' => $id, 'post_title' => sanitize_text_field( $a['title'] ) ] ) );
1877 }
1878 if ( $a['alt'] ?? '' ) {
1879 update_post_meta( $id, '_wp_attachment_image_alt', sanitize_text_field( $a['alt'] ) );
1880 }
1881 $this->add_result_text( $r, wp_get_attachment_url( $id ) );
1882 }
1883 catch ( \Throwable $e ) {
1884 $r['error'] = [ 'code' => $e->getCode() ?: -32603, 'message' => $e->getMessage() ];
1885 }
1886 break;
1887
1888 /* ===== Media: upload alternative (two-step) ===== */
1889 case 'wp_upload_request':
1890 if ( empty( $a['filename'] ) ) {
1891 $r['error'] = [ 'code' => -32602, 'message' => 'filename required' ];
1892 break;
1893 }
1894 try {
1895 $token = wp_generate_password( 32, false );
1896 $transient_key = 'mwai_mcp_upload_' . $token;
1897 $data = [
1898 'filename' => sanitize_file_name( $a['filename'] ),
1899 'title' => $a['title'] ?? '',
1900 'description' => $a['description'] ?? '',
1901 'alt' => $a['alt'] ?? '',
1902 ];
1903 set_transient( $transient_key, $data, 5 * MINUTE_IN_SECONDS );
1904 $upload_url = rest_url( 'mcp/v1/upload/' . $token );
1905 $this->add_result_text( $r, wp_json_encode( [
1906 'upload_url' => $upload_url,
1907 'expires_in' => '5 minutes',
1908 'usage' => 'curl -X POST -F "file=@/path/to/' . $a['filename'] . '" "' . $upload_url . '"',
1909 ], JSON_PRETTY_PRINT ) );
1910 }
1911 catch ( \Throwable $e ) {
1912 $r['error'] = [ 'code' => $e->getCode() ?: -32603, 'message' => $e->getMessage() ];
1913 }
1914 break;
1915
1916 /* ===== Media: update ===== */
1917 case 'wp_update_media':
1918 if ( empty( $a['ID'] ) ) {
1919 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
1920 break;
1921 }
1922 $upd = [ 'ID' => intval( $a['ID'] ) ];
1923 if ( $a['title'] ?? '' ) {
1924 $upd['post_title'] = sanitize_text_field( $a['title'] );
1925 }
1926 if ( $a['caption'] ?? '' ) {
1927 $upd['post_excerpt'] = $this->clean_html( $a['caption'] );
1928 }
1929 if ( $a['description'] ?? '' ) {
1930 $upd['post_content'] = $this->clean_html( $a['description'] );
1931 }
1932 $u = wp_update_post( wp_slash( $upd ), true );
1933 if ( is_wp_error( $u ) ) {
1934 $r['error'] = [ 'code' => $u->get_error_code(), 'message' => $u->get_error_message() ];
1935 }
1936 else {
1937 if ( $a['alt'] ?? '' ) {
1938 update_post_meta( $u, '_wp_attachment_image_alt', sanitize_text_field( $a['alt'] ) );
1939 }
1940 $this->add_result_text( $r, 'Media #' . $u . ' updated' );
1941 }
1942 break;
1943
1944 /* ===== Media: delete ===== */
1945 case 'wp_delete_media':
1946 if ( empty( $a['ID'] ) ) {
1947 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
1948 break;
1949 }
1950 $d = wp_delete_post( intval( $a['ID'] ), !empty( $a['force'] ) );
1951 if ( $d ) {
1952 $this->add_result_text( $r, 'Media #' . $a['ID'] . ' deleted' );
1953 }
1954 else {
1955 $r['error'] = [ 'code' => -32603, 'message' => 'Deletion failed' ];
1956 }
1957 break;
1958
1959 /* ===== MWAI Vision ===== */
1960 case 'mwai_vision':
1961 if ( empty( $a['message'] ) ) {
1962 $r['error'] = [ 'code' => -32602, 'message' => 'message required' ];
1963 break;
1964 }
1965 global $mwai;
1966 if ( !isset( $mwai ) ) {
1967 $r['error'] = [ 'code' => -32603, 'message' => 'MWAI not found' ];
1968 break;
1969 }
1970 $analysis = $mwai->simpleVisionQuery(
1971 $a['message'],
1972 $a['url'] ?? null,
1973 $a['path'] ?? null,
1974 [ 'scope' => 'mcp' ]
1975 );
1976 $this->add_result_text( $r, is_string( $analysis ) ? $analysis : wp_json_encode( $analysis, JSON_PRETTY_PRINT ) );
1977 break;
1978
1979 /* ===== MWAI Image ===== */
1980 case 'mwai_image':
1981 if ( empty( $a['message'] ) ) {
1982 $r['error'] = [ 'code' => -32602, 'message' => 'message required' ];
1983 break;
1984 }
1985 global $mwai;
1986 if ( !isset( $mwai ) ) {
1987 $r['error'] = [ 'code' => -32603, 'message' => 'MWAI not found' ];
1988 break;
1989 }
1990
1991 $media = $mwai->imageQueryForMediaLibrary( $a['message'], [ 'scope' => 'mcp' ], $a['postId'] ?? null );
1992 if ( is_wp_error( $media ) ) {
1993 $r['error'] = [ 'code' => $media->get_error_code(), 'message' => $media->get_error_message() ];
1994 break;
1995 }
1996
1997 $mid = intval( $media['id'] );
1998
1999 $upd = [ 'ID' => $mid ];
2000 if ( !empty( $a['title'] ) ) {
2001 $upd['post_title'] = sanitize_text_field( $a['title'] );
2002 }
2003 if ( !empty( $a['caption'] ) ) {
2004 $upd['post_excerpt'] = $this->clean_html( $a['caption'] );
2005 }
2006 if ( !empty( $a['description'] ) ) {
2007 $upd['post_content'] = $this->clean_html( $a['description'] );
2008 }
2009 if ( count( $upd ) > 1 ) {
2010 wp_update_post( wp_slash( $upd ), true );
2011 }
2012 if ( array_key_exists( 'alt', $a ) ) {
2013 update_post_meta( $mid, '_wp_attachment_image_alt', sanitize_text_field( (string) $a['alt'] ) );
2014 }
2015
2016 $media = [
2017 'id' => $mid,
2018 'url' => wp_get_attachment_url( $mid ),
2019 'title' => get_the_title( $mid ),
2020 'caption' => wp_get_attachment_caption( $mid ),
2021 'alt' => get_post_meta( $mid, '_wp_attachment_image_alt', true ),
2022 ];
2023 $this->add_result_text( $r, wp_json_encode( $media, JSON_PRETTY_PRINT ) );
2024 break;
2025
2026 default: $r['error'] = [ 'code' => -32601, 'message' => 'Unknown tool' ];
2027 }
2028
2029 // Generic post-write hook: fires after any successful content-mutating tool
2030 // (create/update/delete of posts, terms, meta, media, comments, users,
2031 // options...). Integrations can hook this to purge page/object caches, reindex
2032 // search, write an audit log, etc. The options/object cache is already updated
2033 // by WordPress, but full-page caches (Varnish, WP Rocket, Cloudflare) are not,
2034 // so a cache layer should listen here. Reads never trigger it.
2035 if ( empty( $r['error'] ) && $this->is_mutating_tool( $tool ) ) {
2036 do_action( 'mwai_mcp_mutate', $tool, $a, $r );
2037 }
2038 return $r;
2039 }
2040
2041 // Whether a tool changes site state (so the mwai_mcp_mutate hook should fire).
2042 // Anything declared accessLevel "write" mutates; a few "admin" tools mutate too
2043 // (the rest, e.g. wp_get_option, are reads).
2044 private function is_mutating_tool( string $tool ): bool {
2045 $defs = $this->tools();
2046 if ( ( $defs[ $tool ]['accessLevel'] ?? '' ) === 'write' ) {
2047 return true;
2048 }
2049 return in_array( $tool, [ 'wp_update_option', 'wp_create_user', 'wp_update_user' ], true );
2050 }
2051 #endregion
2052 }
2053