PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.6.2
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.6.2
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 1 week ago mcp-oauth.php 1 month ago mcp-rest.php 1 month ago mcp.conf 1 year ago mcp.js 9 months ago mcp.md 9 months ago mcp.php 3 weeks ago wpai-connectors.php 2 months ago wpai-gateway-availability.php 2 months ago wpai-gateway-directory.php 2 months ago wpai-gateway-image-model.php 2 months ago wpai-gateway-model.php 2 months ago wpai-gateway-providers.php 2 months ago wpai-gateway.php 2 months ago
mcp-core.php
2462 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 // Store post_content/excerpt the way core does for an admin editing in wp-admin:
29 // callers who can post unfiltered HTML (administrators on single-site, which is
30 // how the MCP request is authenticated) keep their markup verbatim, so shortcode
31 // attributes, email/Outlook MSO conditionals, and inline CSS survive. Lower-privilege
32 // callers still get wp_kses_post(). Previously this always ran wp_kses_post(),
33 // silently stripping that markup even for admin-authorized writes.
34 private function store_html( string $v ): string {
35 return current_user_can( 'unfiltered_html' ) ? $v : $this->clean_html( $v );
36 }
37
38 // Return stored post_content verbatim for read tools (wp_get_post, snapshot).
39 // The DB value is NOT slashed, so clean_html()'s wp_unslash() would strip the
40 // real backslash from block-JSON Unicode escapes (Gutenberg's \uXXXX form for
41 // < and >, as Rank Math FAQ blocks use) and wp_kses_post() would drop the
42 // admin-authored markup that store_html() preserved on write. A read must
43 // round-trip losslessly through store_html(), so it returns the value as-is.
44 private function read_html( string $v ): string {
45 return $v;
46 }
47
48 // Prepare post_content for wp_create_post. If the caller already sent HTML,
49 // Gutenberg blocks, or shortcodes, keep it as-is (sanitized like the update
50 // path) instead of running the markdown parser. Parsedown would HTML-encode
51 // the quotes in shortcode attributes ([x a="b"] -> a=&quot;b&quot;), auto-link
52 // URLs, and <p>-wrap lines, silently breaking shortcode rendering. Markdown
53 // conversion is reserved for plain prose with no existing markup.
54 private function prepare_new_content( string $v ): string {
55 $hasBlocks = strpos( $v, '<!-- wp:' ) !== false;
56 $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 );
57 // Generic shortcode detection (independent of whether the shortcode is
58 // registered on THIS site): an attribute assignment inside brackets
59 // [name attr="x"] or a closing [/name]. Deliberately does not match a
60 // Markdown link [text](url), which has neither "=" nor a leading slash.
61 $hasShortcode = (bool) preg_match( '/\[[a-zA-Z][\w-]*\s+[^\]]*?=[^\]]*\]|\[\/[a-zA-Z]/', $v );
62 if ( $hasBlocks || $hasHtml || $hasShortcode ) {
63 return $this->store_html( $v );
64 }
65 return $this->core->markdown_to_html( $v );
66 }
67
68 // Recursively blank out every block's attributes. Gallery/media blocks (e.g.
69 // meow-gallery) store their whole image list as JSON in the block-delimiter
70 // comment, which can be hundreds of KB and overflows the tool's token cap on
71 // read. Keep the small delimiter marker and the inner prose/HTML.
72 private function strip_block_attrs( array $blocks ): array {
73 foreach ( $blocks as &$b ) {
74 $b['attrs'] = [];
75 if ( !empty( $b['innerBlocks'] ) ) {
76 $b['innerBlocks'] = $this->strip_block_attrs( $b['innerBlocks'] );
77 }
78 }
79 unset( $b );
80 return $blocks;
81 }
82
83 // Return the post content with block-attribute JSON stripped, so a gallery-heavy
84 // post collapses to its few KB of actual prose without re-rendering any block.
85 private function prose_content( string $v ): string {
86 // $v is raw post_content from get_post(), which is NOT slashed; wp_unslash()
87 // here would strip the real backslash from block-JSON Unicode escapes (the
88 // \uXXXX form Gutenberg uses for < and > in Rank Math FAQ etc.) and corrupt it.
89 return trim( serialize_blocks( $this->strip_block_attrs( parse_blocks( $v ) ) ) );
90 }
91 private function post_excerpt( WP_Post $p ): string {
92 return wp_trim_words( wp_strip_all_tags( $p->post_excerpt ?: $p->post_content ), 55 );
93 }
94 private function empty_schema(): array {
95 return [ 'type' => 'object', 'properties' => (object) [] ];
96 }
97
98 // v1 block types accepted by wp_write_blocks. Kept intentionally small and
99 // conservative: only core blocks whose canonical save markup is stable across
100 // WP 6.x, so the output opens in the block editor without "invalid content"
101 // warnings. The 'html' type is the escape hatch for anything not covered.
102 private static $write_block_types = [
103 'paragraph', 'heading', 'list', 'quote', 'image', 'buttons',
104 'group', 'columns', 'separator', 'spacer', 'code', 'html',
105 ];
106
107 // Render a simplified block-spec array into canonical Gutenberg markup.
108 // Returns [ markup, error ] with exactly one non-null. Aborts on the first bad
109 // block so we never write a half-built page.
110 private function blocks_to_markup( $blocks, string $path = 'blocks' ): array {
111 if ( !is_array( $blocks ) || $blocks === [] ) {
112 return [ null, $path . ' must be a non-empty array of block specs.' ];
113 }
114 $out = [];
115 foreach ( $blocks as $i => $block ) {
116 $at = $path . '[' . $i . ']';
117 if ( !is_array( $block ) || empty( $block['type'] ) || !is_string( $block['type'] ) ) {
118 return [ null, $at . ' is missing a string "type".' ];
119 }
120 if ( !in_array( $block['type'], self::$write_block_types, true ) ) {
121 return [ null, $at . ' has unsupported type "' . $block['type'] . '". Supported: ' . implode( ', ', self::$write_block_types ) . '.' ];
122 }
123 list( $markup, $err ) = $this->render_block_spec( $block['type'], $block, $at );
124 if ( $err !== null ) {
125 return [ null, $err ];
126 }
127 $out[] = $markup;
128 }
129 return [ implode( "\n\n", $out ), null ];
130 }
131
132 // Build the canonical markup for one supported block. Returns [ markup, error ].
133 private function render_block_spec( string $type, array $b, string $at ): array {
134 switch ( $type ) {
135 case 'paragraph':
136 return [ "<!-- wp:paragraph -->\n<p>" . $this->clean_html( $b['content'] ?? '' ) . "</p>\n<!-- /wp:paragraph -->", null ];
137
138 case 'heading':
139 $level = isset( $b['level'] ) ? (int) $b['level'] : 2;
140 if ( $level < 1 || $level > 6 ) {
141 return [ null, $at . ' heading level must be between 1 and 6.' ];
142 }
143 $attrs = $level === 2 ? '' : ' ' . wp_json_encode( [ 'level' => $level ] );
144 $text = $this->clean_html( $b['content'] ?? '' );
145 return [ '<!-- wp:heading' . $attrs . " -->\n<h" . $level . ' class="wp-block-heading">' . $text . '</h' . $level . ">\n<!-- /wp:heading -->", null ];
146
147 case 'list':
148 $items = $b['items'] ?? null;
149 if ( !is_array( $items ) || $items === [] ) {
150 return [ null, $at . ' list requires a non-empty "items" array of strings.' ];
151 }
152 $ordered = !empty( $b['ordered'] );
153 $tag = $ordered ? 'ol' : 'ul';
154 $listAttrs = $ordered ? ' ' . wp_json_encode( [ 'ordered' => true ] ) : '';
155 $lis = '';
156 foreach ( $items as $it ) {
157 $lis .= "<!-- wp:list-item -->\n<li>" . $this->clean_html( is_string( $it ) ? $it : '' ) . "</li>\n<!-- /wp:list-item -->\n";
158 }
159 return [ '<!-- wp:list' . $listAttrs . " -->\n<" . $tag . ' class="wp-block-list">' . rtrim( $lis, "\n" ) . '</' . $tag . ">\n<!-- /wp:list -->", null ];
160
161 case 'quote':
162 $qInner = "<!-- wp:paragraph -->\n<p>" . $this->clean_html( $b['content'] ?? '' ) . "</p>\n<!-- /wp:paragraph -->";
163 $cite = ( isset( $b['citation'] ) && $b['citation'] !== '' ) ? '<cite>' . $this->clean_html( $b['citation'] ) . '</cite>' : '';
164 return [ "<!-- wp:quote -->\n<blockquote class=\"wp-block-quote\">" . $qInner . $cite . "</blockquote>\n<!-- /wp:quote -->", null ];
165
166 case 'image':
167 $url = esc_url_raw( $b['url'] ?? '' );
168 if ( $url === '' ) {
169 return [ null, $at . ' image requires a "url".' ];
170 }
171 $alt = esc_attr( $b['alt'] ?? '' );
172 $caption = ( isset( $b['caption'] ) && $b['caption'] !== '' ) ? '<figcaption class="wp-element-caption">' . $this->clean_html( $b['caption'] ) . '</figcaption>' : '';
173 $img = '<img src="' . $url . '" alt="' . $alt . '"/>';
174 return [ "<!-- wp:image -->\n<figure class=\"wp-block-image\">" . $img . $caption . "</figure>\n<!-- /wp:image -->", null ];
175
176 case 'buttons':
177 $buttons = $b['buttons'] ?? null;
178 if ( !is_array( $buttons ) || $buttons === [] ) {
179 return [ null, $at . ' buttons requires a non-empty "buttons" array of {text, url}.' ];
180 }
181 $btnInner = '';
182 foreach ( $buttons as $bi => $btn ) {
183 if ( !is_array( $btn ) || empty( $btn['text'] ) ) {
184 return [ null, $at . ' button[' . $bi . '] requires "text".' ];
185 }
186 $href = esc_url_raw( $btn['url'] ?? '' );
187 $hrefAttr = $href !== '' ? ' href="' . $href . '"' : '';
188 $btnInner .= "<!-- wp:button -->\n<div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\"" . $hrefAttr . '>' . $this->clean_html( $btn['text'] ) . "</a></div>\n<!-- /wp:button -->\n";
189 }
190 return [ "<!-- wp:buttons -->\n<div class=\"wp-block-buttons\">" . rtrim( $btnInner, "\n" ) . "</div>\n<!-- /wp:buttons -->", null ];
191
192 case 'group':
193 list( $gInner, $gErr ) = $this->blocks_to_markup( $b['blocks'] ?? null, $at . '.blocks' );
194 if ( $gErr !== null ) {
195 return [ null, $gErr ];
196 }
197 return [ "<!-- wp:group -->\n<div class=\"wp-block-group\">" . $gInner . "</div>\n<!-- /wp:group -->", null ];
198
199 case 'columns':
200 $columns = $b['columns'] ?? null;
201 if ( !is_array( $columns ) || $columns === [] ) {
202 return [ null, $at . ' columns requires a non-empty "columns" array (an array of block-spec arrays).' ];
203 }
204 $colsInner = '';
205 foreach ( $columns as $ci => $colBlocks ) {
206 list( $colInner, $colErr ) = $this->blocks_to_markup( $colBlocks, $at . '.columns[' . $ci . ']' );
207 if ( $colErr !== null ) {
208 return [ null, $colErr ];
209 }
210 $colsInner .= "<!-- wp:column -->\n<div class=\"wp-block-column\">" . $colInner . "</div>\n<!-- /wp:column -->\n";
211 }
212 return [ "<!-- wp:columns -->\n<div class=\"wp-block-columns\">" . rtrim( $colsInner, "\n" ) . "</div>\n<!-- /wp:columns -->", null ];
213
214 case 'separator':
215 return [ "<!-- wp:separator -->\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"/>\n<!-- /wp:separator -->", null ];
216
217 case 'spacer':
218 $h = isset( $b['height'] ) ? (int) $b['height'] : 100;
219 if ( $h < 1 || $h > 2000 ) {
220 return [ null, $at . ' spacer height must be between 1 and 2000 (px).' ];
221 }
222 return [ '<!-- wp:spacer ' . wp_json_encode( [ 'height' => $h . 'px' ] ) . " -->\n<div style=\"height:" . $h . "px\" aria-hidden=\"true\" class=\"wp-block-spacer\"></div>\n<!-- /wp:spacer -->", null ];
223
224 case 'code':
225 return [ "<!-- wp:code -->\n<pre class=\"wp-block-code\"><code>" . esc_html( wp_unslash( (string) ( $b['content'] ?? '' ) ) ) . "</code></pre>\n<!-- /wp:code -->", null ];
226
227 case 'html':
228 // core/html stores raw HTML and is always valid on re-open. Sanitize to post-safe HTML.
229 return [ "<!-- wp:html -->\n" . $this->clean_html( $b['content'] ?? '' ) . "\n<!-- /wp:html -->", null ];
230 }
231 return [ null, $at . ' could not be rendered.' ];
232 }
233
234 /**
235 * Compile a wp_alter_post regex search into a delimited PCRE pattern.
236 *
237 * The documented contract is a BARE pattern plus an optional flags string; we wrap it
238 * with a safe delimiter internally. This is what makes Gutenberg block markers work:
239 * they contain "/" (e.g. <!-- /wp:paragraph -->), which collides with the "/" delimiter,
240 * so "/" is tried last when picking a delimiter. For backward compatibility a pattern
241 * that already compiles as a fully delimited PCRE (and no separate flags were given) is
242 * honored as-is. Returns [ compiled, error ]; exactly one is non-null.
243 */
244 private function compile_alter_regex( string $pattern, string $flags = '' ): array {
245 $flags = trim( $flags );
246 if ( $flags !== '' && !preg_match( '/^[imsxuADSUXJ]+$/', $flags ) ) {
247 return [ null, 'Invalid regex flags "' . $flags . '". Allowed: i, m, s, x, u, A, D, S, U, X, J.' ];
248 }
249
250 // Backward compat: an already-delimited pattern that compiles is used verbatim.
251 if ( $flags === '' && $pattern !== '' && $this->preg_compile_error( $pattern ) === null ) {
252 return [ $pattern, null ];
253 }
254
255 // Bare pattern: wrap with the first delimiter not present in the pattern ("/" last).
256 $delimiter = '';
257 foreach ( [ '~', '#', '%', '!', '@', '/' ] as $candidate ) {
258 if ( strpos( $pattern, $candidate ) === false ) {
259 $delimiter = $candidate;
260 break;
261 }
262 }
263 if ( $delimiter === '' ) {
264 // Pattern uses every candidate; fall back to "~" and escape its occurrences.
265 $delimiter = '~';
266 $pattern = str_replace( '~', '\~', $pattern );
267 }
268 $compiled = $delimiter . $pattern . $delimiter . $flags;
269
270 $err = $this->preg_compile_error( $compiled );
271 if ( $err !== null ) {
272 return [ null, 'Invalid regex pattern: ' . $err . ' (compiled to ' . $compiled . ')' ];
273 }
274 return [ $compiled, null ];
275 }
276
277 /**
278 * Test-compile a PCRE pattern without emitting warnings. Returns null on success, or a
279 * human-readable PCRE error message (echoing the real engine message when available).
280 */
281 private function preg_compile_error( string $pattern ): ?string {
282 set_error_handler( fn () => true );
283 $result = preg_match( $pattern, '' );
284 restore_error_handler();
285 if ( $result !== false ) {
286 return null;
287 }
288 return function_exists( 'preg_last_error_msg' )
289 ? preg_last_error_msg()
290 : 'PCRE error code ' . preg_last_error();
291 }
292
293 /**
294 * Bust post caches after a write so a follow-up wp_get_post in the next request
295 * returns fresh data on sites with persistent object caches (Redis, Memcached) or
296 * page caches (LiteSpeed, WP Rocket, Cloudflare, etc.). wp_insert_post / wp_update_post
297 * call clean_post_cache themselves; this is idempotent and also fans out third-party
298 * purge hooks plus a generic mwai_mcp_post_changed action so sites can wire their own.
299 *
300 * Per-request dedupe: agentic clients often hit the same post several times in quick
301 * succession (e.g. wp_alter_post twice on the same page within the same JSON-RPC call),
302 * which would multiply expensive third-party purges (Cloudflare global, Algolia reindex).
303 * We keep a static set of post IDs already busted in this PHP request and short-circuit
304 * repeats. The $context array is forwarded to mwai_mcp_post_changed so handlers can
305 * coalesce or defer purges across requests on their own (e.g. flush at end of batch).
306 */
307 private function bust_post_cache( int $post_id, array $context = [] ): void {
308 if ( $post_id <= 0 ) {
309 return;
310 }
311 static $already_busted = [];
312 if ( isset( $already_busted[ $post_id ] ) ) {
313 return;
314 }
315 $already_busted[ $post_id ] = true;
316
317 clean_post_cache( $post_id );
318 $context = wp_parse_args( $context, [
319 'source' => 'mcp',
320 'tool' => null,
321 'batch' => false,
322 ] );
323 do_action( 'mwai_mcp_post_changed', $post_id, $context );
324 do_action( 'litespeed_purge_post', $post_id );
325 if ( function_exists( 'rocket_clean_post' ) ) {
326 rocket_clean_post( $post_id );
327 }
328 }
329 #endregion
330
331 #region Tools Definitions
332 private function tools(): array {
333 return [
334
335 /* -------- Plugins -------- */
336 'wp_list_plugins' => [
337 'name' => 'wp_list_plugins',
338 'description' => 'List installed plugins (returns array of {Name, Version}).',
339 'inputSchema' => [
340 'type' => 'object',
341 'properties' => [ 'search' => [ 'type' => 'string' ] ],
342 ],
343 'accessLevel' => 'read',
344 ],
345
346 /* -------- Users -------- */
347 'wp_get_users' => [
348 'name' => 'wp_get_users',
349 'description' => 'Retrieve users (fields: ID, user_login, display_name, roles). If no limit supplied, returns 10. `paged` ignored if `offset` is used.',
350 'inputSchema' => [
351 'type' => 'object',
352 'properties' => [
353 'search' => [ 'type' => 'string' ],
354 'role' => [ 'type' => 'string' ],
355 'limit' => [ 'type' => 'integer' ],
356 'offset' => [ 'type' => 'integer' ],
357 'paged' => [ 'type' => 'integer' ],
358 ],
359 ],
360 'accessLevel' => 'admin',
361 ],
362 'wp_create_user' => [
363 'name' => 'wp_create_user',
364 'description' => 'Create a user. Requires user_login and user_email. Optional: user_pass (random if omitted), display_name, role.',
365 'inputSchema' => [
366 'type' => 'object',
367 'properties' => [
368 'user_login' => [ 'type' => 'string' ],
369 'user_email' => [ 'type' => 'string' ],
370 'user_pass' => [ 'type' => 'string' ],
371 'display_name' => [ 'type' => 'string' ],
372 'role' => [ 'type' => 'string' ],
373 ],
374 'required' => [ 'user_login', 'user_email' ],
375 ],
376 'accessLevel' => 'admin',
377 ],
378 'wp_update_user' => [
379 'name' => 'wp_update_user',
380 'description' => 'Update a user – pass ID plus a “fields” object (user_email, display_name, user_pass, role).',
381 'inputSchema' => [
382 'type' => 'object',
383 'properties' => [
384 'ID' => [ 'type' => 'integer' ],
385 'fields' => [
386 'type' => 'object',
387 'properties' => [
388 'user_email' => [ 'type' => 'string' ],
389 'display_name' => [ 'type' => 'string' ],
390 'user_pass' => [ 'type' => 'string' ],
391 'role' => [ 'type' => 'string' ],
392 ],
393 'additionalProperties' => true
394 ],
395 ],
396 'required' => [ 'ID' ],
397 ],
398 'accessLevel' => 'admin',
399 ],
400
401 /* -------- Comments -------- */
402 'wp_get_comments' => [
403 'name' => 'wp_get_comments',
404 'description' => 'Retrieve comments (fields: comment_ID, comment_post_ID, comment_type, comment_author, comment_content, comment_date, comment_approved). Returns 10 by default. Filter by commenter with `user_id` (registered user ID) or `author_email`. Use `type` to filter by comment type; pass `type: "note"` to read WordPress 6.9 editor Notes (block-level feedback), where comment_approved "0" means open/unresolved and "1" means resolved. When reading notes, all statuses are returned unless you pass an explicit `status`.',
405 'inputSchema' => [
406 'type' => 'object',
407 'properties' => [
408 'post_id' => [ 'type' => 'integer' ],
409 'status' => [ 'type' => 'string' ],
410 'type' => [ 'type' => 'string', 'description' => 'Filter by comment type, e.g. "comment", "pingback", or "note" (WP 6.9 editor Notes). Omit to return all types.' ],
411 'search' => [ 'type' => 'string' ],
412 'user_id' => [ 'type' => 'integer', 'description' => 'Filter by the registered user ID of the commenter.' ],
413 'author_email' => [ 'type' => 'string', 'description' => 'Filter by the commenter email address.' ],
414 'limit' => [ 'type' => 'integer' ],
415 'offset' => [ 'type' => 'integer' ],
416 'paged' => [ 'type' => 'integer' ],
417 ],
418 ],
419 'accessLevel' => 'read',
420 ],
421 'wp_create_comment' => [
422 'name' => 'wp_create_comment',
423 'description' => 'Insert a comment. Requires post_id and comment_content. Optional author, author_email, author_url.',
424 'inputSchema' => [
425 'type' => 'object',
426 'properties' => [
427 'post_id' => [ 'type' => 'integer' ],
428 'comment_content' => [ 'type' => 'string' ],
429 'comment_author' => [ 'type' => 'string' ],
430 'comment_author_email' => [ 'type' => 'string' ],
431 'comment_author_url' => [ 'type' => 'string' ],
432 'comment_approved' => [ 'type' => 'string' ],
433 ],
434 'required' => [ 'post_id', 'comment_content' ],
435 ],
436 'accessLevel' => 'write',
437 ],
438 'wp_update_comment' => [
439 'name' => 'wp_update_comment',
440 'description' => 'Update a comment – pass comment_ID plus fields (comment_content, comment_approved).',
441 'inputSchema' => [
442 'type' => 'object',
443 'properties' => [
444 'comment_ID' => [ 'type' => 'integer' ],
445 'fields' => [
446 'type' => 'object',
447 'properties' => [
448 'comment_content' => [ 'type' => 'string' ],
449 'comment_approved' => [ 'type' => 'string' ],
450 ],
451 'additionalProperties' => true
452 ],
453 ],
454 'required' => [ 'comment_ID' ],
455 ],
456 'accessLevel' => 'write',
457 ],
458 'wp_delete_comment' => [
459 'name' => 'wp_delete_comment',
460 'description' => 'Delete a comment. `force` true bypasses trash.',
461 'inputSchema' => [
462 'type' => 'object',
463 'properties' => [
464 'comment_ID' => [ 'type' => 'integer' ],
465 'force' => [ 'type' => 'boolean' ],
466 ],
467 'required' => [ 'comment_ID' ],
468 ],
469 'accessLevel' => 'admin',
470 ],
471
472 /* -------- Options -------- */
473 'wp_get_option' => [
474 'name' => 'wp_get_option',
475 '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).',
476 'inputSchema' => [
477 'type' => 'object',
478 'properties' => [
479 'key' => [ 'type' => 'string' ],
480 'raw' => [ 'type' => 'boolean', 'description' => 'Read the unfiltered value directly from the database (bypasses object cache and option_* filters).' ],
481 ],
482 'required' => [ 'key' ],
483 ],
484 'accessLevel' => 'admin',
485 ],
486 'wp_update_option' => [
487 'name' => 'wp_update_option',
488 '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.',
489 'inputSchema' => [
490 'type' => 'object',
491 'properties' => [
492 'key' => [ 'type' => 'string' ],
493 // No type constraint here on purpose: WordPress options accept any
494 // value (string, number, boolean, array, object). Declaring a union
495 // that includes "object"/"array" makes ChatGPT reject the schema,
496 // and the runtime normalizer would strip the type anyway and log a
497 // warning every list_tools call. Keep it permissive from the start.
498 'value' => [ 'description' => 'Option value. Accepts strings, numbers, booleans, arrays, or objects (non-scalars are JSON-serialised).' ],
499 ],
500 'required' => [ 'key', 'value' ],
501 ],
502 'accessLevel' => 'admin',
503 ],
504
505 /* -------- Counts -------- */
506 'wp_count_posts' => [
507 'name' => 'wp_count_posts',
508 'description' => 'Return counts of posts by status. Optional post_type (default post).',
509 'inputSchema' => [
510 'type' => 'object',
511 'properties' => [ 'post_type' => [ 'type' => 'string' ] ],
512 ],
513 'accessLevel' => 'read',
514 ],
515 'wp_count_terms' => [
516 'name' => 'wp_count_terms',
517 'description' => 'Return total number of terms in a taxonomy.',
518 'inputSchema' => [
519 'type' => 'object',
520 'properties' => [ 'taxonomy' => [ 'type' => 'string' ] ],
521 'required' => [ 'taxonomy' ],
522 ],
523 'accessLevel' => 'read',
524 ],
525 'wp_count_media' => [
526 'name' => 'wp_count_media',
527 'description' => 'Return number of attachments (optionally after/before date).',
528 'inputSchema' => [
529 'type' => 'object',
530 'properties' => [
531 'after' => [ 'type' => 'string' ],
532 'before' => [ 'type' => 'string' ],
533 ],
534 ],
535 'accessLevel' => 'read',
536 ],
537
538 /* -------- Post-types -------- */
539 'wp_get_post_types' => [
540 'name' => 'wp_get_post_types',
541 'description' => 'List public post types (key, label).',
542 'inputSchema' => $this->empty_schema(),
543 'accessLevel' => 'read',
544 ],
545
546 /* -------- Posts -------- */
547 'wp_get_posts' => [
548 'name' => 'wp_get_posts',
549 '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).',
550 'inputSchema' => [
551 'type' => 'object',
552 'properties' => [
553 'post_type' => [ 'type' => 'string' ],
554 'post_status' => [ 'type' => 'string' ],
555 'search' => [ 'type' => 'string' ],
556 'author' => [ 'type' => 'integer', 'description' => 'Filter by author user ID.' ],
557 'author_name' => [ 'type' => 'string', 'description' => 'Filter by author user slug (nicename). Ignored if author is set.' ],
558 'author__not_in' => [ 'type' => 'array', 'items' => [ 'type' => 'integer' ], 'description' => 'Exclude posts by these author user IDs.' ],
559 'after' => [ 'type' => 'string' ],
560 'before' => [ 'type' => 'string' ],
561 'limit' => [ 'type' => 'integer' ],
562 'offset' => [ 'type' => 'integer' ],
563 'paged' => [ 'type' => 'integer' ],
564 ],
565 ],
566 'accessLevel' => 'read',
567 ],
568 'wp_get_post' => [
569 'name' => 'wp_get_post',
570 '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.',
571 'inputSchema' => [
572 'type' => 'object',
573 'properties' => [
574 'ID' => [ 'type' => 'integer' ],
575 'content_format' => [ 'type' => 'string', 'enum' => [ 'full', 'prose' ], 'description' => 'full (default) returns raw content; prose strips block-attribute JSON, keeping prose, headings and block markers.' ],
576 ],
577 'required' => [ 'ID' ],
578 ],
579 'accessLevel' => 'read',
580 ],
581 'wp_get_post_snapshot' => [
582 'name' => 'wp_get_post_snapshot',
583 '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.',
584 'inputSchema' => [
585 'type' => 'object',
586 'properties' => [
587 'ID' => [ 'type' => 'integer', 'description' => 'Post ID' ],
588 'include' => [
589 'type' => 'array',
590 'description' => 'Optional: fields to include (default: all). Options: meta, terms, thumbnail, author',
591 'items' => [ 'type' => 'string' ],
592 ],
593 'exclude' => [
594 'type' => 'array',
595 'description' => 'Optional: fields to exclude from post data. Options: content (useful for posts with huge content like many galleries)',
596 'items' => [ 'type' => 'string' ],
597 ],
598 '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.' ],
599 ],
600 'required' => [ 'ID' ],
601 ],
602 'accessLevel' => 'read',
603 ],
604 'wp_create_post' => [
605 'name' => 'wp_create_post',
606 '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.',
607 'inputSchema' => [
608 'type' => 'object',
609 'properties' => [
610 'post_title' => [ 'type' => 'string' ],
611 'post_content' => [ 'type' => 'string' ],
612 'post_excerpt' => [ 'type' => 'string' ],
613 'post_status' => [ 'type' => 'string' ],
614 'post_type' => [ 'type' => 'string' ],
615 'post_name' => [ 'type' => 'string' ],
616 'meta_input' => [ 'type' => 'object', 'description' => 'Associative array of custom fields.' ],
617 ],
618 'required' => [ 'post_title' ],
619 ],
620 'accessLevel' => 'write',
621 ],
622 'wp_update_post' => [
623 'name' => 'wp_update_post',
624 '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.',
625 'inputSchema' => [
626 'type' => 'object',
627 'properties' => [
628 'ID' => [ 'type' => 'integer', 'description' => 'The ID of the post to update.' ],
629 'fields' => [
630 'type' => 'object',
631 'properties' => [
632 'post_title' => [ 'type' => 'string' ],
633 'post_content' => [ 'type' => 'string' ],
634 'post_status' => [ 'type' => 'string' ],
635 'post_name' => [ 'type' => 'string' ],
636 'post_excerpt' => [ 'type' => 'string' ],
637 'post_category' => [ 'type' => 'array', 'items' => [ 'type' => 'integer' ] ],
638 ],
639 'additionalProperties' => true
640 ],
641 'meta_input' => [
642 'type' => 'object',
643 'description' => 'Associative array of custom fields.'
644 ],
645 'schedule_for' => [
646 'type' => 'string',
647 '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.'
648 ],
649 ],
650 'required' => [ 'ID' ],
651 ],
652 'accessLevel' => 'write',
653 ],
654 'wp_delete_post' => [
655 'name' => 'wp_delete_post',
656 '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.',
657 'inputSchema' => [
658 'type' => 'object',
659 'properties' => [
660 'ID' => [ 'type' => 'integer' ],
661 'force' => [ 'type' => 'boolean' ],
662 ],
663 'required' => [ 'ID' ],
664 ],
665 'accessLevel' => 'admin',
666 ],
667 'wp_alter_post' => [
668 'name' => 'wp_alter_post',
669 '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.',
670 'inputSchema' => [
671 'type' => 'object',
672 'properties' => [
673 'ID' => [ 'type' => 'integer', 'description' => 'Post ID.' ],
674 'field' => [ 'type' => 'string', 'description' => 'Field to modify: post_content, post_excerpt, or post_title.' ],
675 'search' => [ 'type' => 'string', 'description' => 'Text to search for, or (with regex=true) a bare PCRE pattern without delimiters, e.g. <!-- /wp:paragraph -->\\s*$' ],
676 'replace' => [ 'type' => 'string', 'description' => 'Replacement text. In regex mode, backreferences like $1 / \\1 are supported.' ],
677 'regex' => [ 'type' => 'boolean', 'description' => 'Treat search as a regex pattern (default: false).' ],
678 '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.' ],
679 ],
680 'required' => [ 'ID', 'field', 'search', 'replace' ],
681 ],
682 'accessLevel' => 'write',
683 ],
684 'wp_write_blocks' => [
685 'name' => 'wp_write_blocks',
686 'description' => 'Build a valid Gutenberg (block editor) layout on an existing post or page from a simple block spec, so the result opens cleanly in the editor with no "invalid content" warnings. Create the post first with wp_create_post, then pass its ID plus "blocks", an ordered array of specs like {"type":"heading","level":2,"content":"..."}. Supported types: paragraph (content), heading (content, level 1-6), list (items[], ordered), quote (content, citation), image (url, alt, caption), buttons (buttons[] of {text,url}), group (blocks[]), columns (columns[] of block-spec arrays), separator, spacer (height px), code (content), html (content, raw HTML escape hatch). content fields accept inline HTML. mode replaces (default), appends, or prepends. For prose you do not need to lay out visually, plain wp_create_post/wp_update_post with Markdown is simpler; use this when you want real, editable blocks.',
687 'inputSchema' => [
688 'type' => 'object',
689 'properties' => [
690 'ID' => [ 'type' => 'integer', 'description' => 'Target post/page ID (create it first with wp_create_post).' ],
691 'blocks' => [
692 'type' => 'array',
693 'description' => 'Ordered array of block specs. Each item is an object with a "type" and the fields for that type (see the tool description).',
694 'items' => [ 'type' => 'object', 'additionalProperties' => true ],
695 ],
696 'mode' => [ 'type' => 'string', 'enum' => [ 'replace', 'append', 'prepend' ], 'description' => 'replace (default) overwrites post_content; append/prepend add the blocks to the existing content.' ],
697 ],
698 'required' => [ 'ID', 'blocks' ],
699 ],
700 'accessLevel' => 'write',
701 ],
702 'wp_list_block_patterns' => [
703 'name' => 'wp_list_block_patterns',
704 'description' => 'List the block patterns registered on this site (core, theme, and plugin patterns). Patterns are ready-made, pre-validated block layouts (hero/banner sections, pricing tables, testimonials, galleries, calls to action) authored by the theme, so inserting one is on-brand and always opens cleanly in the editor. Discover a layout here, insert it with wp_insert_block_pattern, then adjust the placeholder text with wp_alter_post. Returns compact metadata (name, title, categories, description) by default; set include_content to true to also get the raw block markup. Filter with search (matches title/name/description/keywords) and/or category (e.g. "call-to-action", "gallery", "testimonials").',
705 'inputSchema' => [
706 'type' => 'object',
707 'properties' => [
708 'search' => [ 'type' => 'string', 'description' => 'Case-insensitive filter on title, name, description, and keywords.' ],
709 'category' => [ 'type' => 'string', 'description' => 'Pattern category slug, e.g. "featured", "call-to-action", "gallery", "testimonials".' ],
710 'include_content' => [ 'type' => 'boolean', 'description' => 'Include each match\'s raw block markup (default false; can be large).' ],
711 'limit' => [ 'type' => 'integer', 'description' => 'Max patterns to return (default 50, max 500).' ],
712 ],
713 ],
714 'accessLevel' => 'read',
715 ],
716 'wp_insert_block_pattern' => [
717 'name' => 'wp_insert_block_pattern',
718 'description' => 'Insert a registered block pattern into a post or page by its name (get names from wp_list_block_patterns). Pattern markup is pre-validated theme/core content, so the result is on-brand and valid in the editor. mode "append" (default) adds it to the end, so you can compose a full page from several patterns in successive calls; "replace" overwrites the content; "prepend" adds it to the top. After inserting, swap placeholder text with wp_alter_post.',
719 'inputSchema' => [
720 'type' => 'object',
721 'properties' => [
722 'ID' => [ 'type' => 'integer', 'description' => 'Target post/page ID (create it first with wp_create_post).' ],
723 'pattern' => [ 'type' => 'string', 'description' => 'Pattern name (slug) from wp_list_block_patterns, e.g. "core/query-standard-posts" or "twentytwentyfive/hero".' ],
724 'mode' => [ 'type' => 'string', 'enum' => [ 'append', 'replace', 'prepend' ], 'description' => 'append (default), replace, or prepend the pattern content.' ],
725 ],
726 'required' => [ 'ID', 'pattern' ],
727 ],
728 'accessLevel' => 'write',
729 ],
730
731 /* -------- Post-meta -------- */
732 'wp_get_post_meta' => [
733 'name' => 'wp_get_post_meta',
734 '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.',
735 'inputSchema' => [
736 'type' => 'object',
737 'properties' => [
738 'ID' => [ 'type' => 'integer' ],
739 'key' => [ 'type' => 'string' ],
740 ],
741 'required' => [ 'ID' ],
742 ],
743 'accessLevel' => 'read',
744 ],
745 'wp_update_post_meta' => [
746 'name' => 'wp_update_post_meta',
747 '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.',
748 'inputSchema' => [
749 'type' => 'object',
750 'properties' => [
751 'ID' => [ 'type' => 'integer' ],
752 'meta' => [ 'type' => 'object', 'description' => 'Key/value pairs to set. Alternative: provide "key" + "value".' ],
753 'key' => [ 'type' => 'string' ],
754 'value' => [ 'type' => [ 'string', 'number', 'boolean' ] ],
755 ],
756 'required' => [ 'ID' ],
757 ],
758 'accessLevel' => 'write',
759 ],
760 'wp_delete_post_meta' => [
761 'name' => 'wp_delete_post_meta',
762 'description' => 'Delete custom field(s) from a post. Provide value to remove a single row; omit value to delete all rows for the key.',
763 'inputSchema' => [
764 'type' => 'object',
765 'properties' => [
766 'ID' => [ 'type' => 'integer' ],
767 'key' => [ 'type' => 'string' ],
768 'value' => [ 'type' => [ 'string', 'number', 'boolean' ] ],
769 ],
770 'required' => [ 'ID', 'key' ],
771 ],
772 'accessLevel' => 'admin',
773 ],
774
775 /* -------- Featured image -------- */
776 'wp_set_featured_image' => [
777 'name' => 'wp_set_featured_image',
778 'description' => 'Attach or remove a featured image (thumbnail) for a post/page. Provide media_id to attach, omit or null to remove.',
779 'inputSchema' => [
780 'type' => 'object',
781 'properties' => [
782 'post_id' => [ 'type' => 'integer' ],
783 'media_id' => [ 'type' => 'integer' ],
784 ],
785 'required' => [ 'post_id' ],
786 ],
787 'accessLevel' => 'write',
788 ],
789
790 /* -------- Taxonomies / Terms -------- */
791 'wp_get_taxonomies' => [
792 'name' => 'wp_get_taxonomies',
793 'description' => 'List taxonomies for a post type.',
794 'inputSchema' => [
795 'type' => 'object',
796 'properties' => [ 'post_type' => [ 'type' => 'string' ] ],
797 ],
798 'accessLevel' => 'read',
799 ],
800 'wp_get_terms' => [
801 'name' => 'wp_get_terms',
802 'description' => 'List terms of a taxonomy.',
803 'inputSchema' => [
804 'type' => 'object',
805 'properties' => [
806 'taxonomy' => [ 'type' => 'string' ],
807 'search' => [ 'type' => 'string' ],
808 'parent' => [ 'type' => 'integer' ],
809 'limit' => [ 'type' => 'integer' ],
810 ],
811 'required' => [ 'taxonomy' ],
812 ],
813 'accessLevel' => 'read',
814 ],
815 'wp_create_term' => [
816 'name' => 'wp_create_term',
817 'description' => 'Create a term.',
818 'inputSchema' => [
819 'type' => 'object',
820 'properties' => [
821 'taxonomy' => [ 'type' => 'string' ],
822 'term_name' => [ 'type' => 'string' ],
823 'slug' => [ 'type' => 'string' ],
824 'description' => [ 'type' => 'string' ],
825 'parent' => [ 'type' => 'integer' ],
826 ],
827 'required' => [ 'taxonomy', 'term_name' ],
828 ],
829 'accessLevel' => 'write',
830 ],
831 'wp_update_term' => [
832 'name' => 'wp_update_term',
833 'description' => 'Update a term.',
834 'inputSchema' => [
835 'type' => 'object',
836 'properties' => [
837 'term_id' => [ 'type' => 'integer' ],
838 'taxonomy' => [ 'type' => 'string' ],
839 'name' => [ 'type' => 'string' ],
840 'slug' => [ 'type' => 'string' ],
841 'description' => [ 'type' => 'string' ],
842 'parent' => [ 'type' => 'integer' ],
843 ],
844 'required' => [ 'term_id', 'taxonomy' ],
845 ],
846 'accessLevel' => 'write',
847 ],
848 'wp_delete_term' => [
849 'name' => 'wp_delete_term',
850 'description' => 'Delete a term.',
851 'inputSchema' => [
852 'type' => 'object',
853 'properties' => [
854 'term_id' => [ 'type' => 'integer' ],
855 'taxonomy' => [ 'type' => 'string' ],
856 ],
857 'required' => [ 'term_id', 'taxonomy' ],
858 ],
859 'accessLevel' => 'admin',
860 ],
861 'wp_get_post_terms' => [
862 'name' => 'wp_get_post_terms',
863 'description' => 'Get terms attached to a post.',
864 'inputSchema' => [
865 'type' => 'object',
866 'properties' => [
867 'ID' => [ 'type' => 'integer' ],
868 'taxonomy' => [ 'type' => 'string' ],
869 ],
870 'required' => [ 'ID' ],
871 ],
872 'accessLevel' => 'read',
873 ],
874 'wp_add_post_terms' => [
875 'name' => 'wp_add_post_terms',
876 '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.).',
877 'inputSchema' => [
878 'type' => 'object',
879 'properties' => [
880 'ID' => [ 'type' => 'integer' ],
881 'taxonomy' => [ 'type' => 'string' ],
882 'terms' => [ 'type' => 'array', 'items' => [ 'type' => 'integer' ] ],
883 'append' => [ 'type' => 'boolean' ],
884 ],
885 'required' => [ 'ID', 'terms' ],
886 ],
887 'accessLevel' => 'write',
888 ],
889
890 /* -------- Media -------- */
891 'wp_get_media' => [
892 'name' => 'wp_get_media',
893 'description' => 'List media items. Filter by uploader with `author` (user ID) or `author_name` (user slug).',
894 'inputSchema' => [
895 'type' => 'object',
896 'properties' => [
897 'search' => [ 'type' => 'string' ],
898 'author' => [ 'type' => 'integer', 'description' => 'Filter by uploader user ID.' ],
899 'author_name' => [ 'type' => 'string', 'description' => 'Filter by uploader user slug (nicename). Ignored if author is set.' ],
900 'after' => [ 'type' => 'string' ],
901 'before' => [ 'type' => 'string' ],
902 'limit' => [ 'type' => 'integer' ],
903 ],
904 ],
905 'accessLevel' => 'read',
906 ],
907 'wp_upload_media' => [
908 'name' => 'wp_upload_media',
909 '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.',
910 'inputSchema' => [
911 'type' => 'object',
912 'properties' => [
913 'url' => [
914 'type' => 'string',
915 'description' => 'URL to download the file from. Use this OR base64/filename.',
916 ],
917 'base64' => [
918 'type' => 'string',
919 'description' => 'Base64-encoded file content. Must be used together with filename.',
920 ],
921 'filename' => [
922 'type' => 'string',
923 'description' => 'Filename with extension (e.g. photo.jpg). Required when using base64.',
924 ],
925 'title' => [ 'type' => 'string' ],
926 'description' => [ 'type' => 'string' ],
927 'alt' => [ 'type' => 'string' ],
928 ],
929 ],
930 'accessLevel' => 'write',
931 ],
932 'wp_upload_request' => [
933 'name' => 'wp_upload_request',
934 '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.',
935 'inputSchema' => [
936 'type' => 'object',
937 'properties' => [
938 'filename' => [
939 'type' => 'string',
940 'description' => 'Filename with extension (e.g. photo.jpg).',
941 ],
942 'title' => [ 'type' => 'string' ],
943 'description' => [ 'type' => 'string' ],
944 'alt' => [ 'type' => 'string' ],
945 ],
946 'required' => [ 'filename' ],
947 ],
948 'accessLevel' => 'write',
949 ],
950 'wp_update_media' => [
951 'name' => 'wp_update_media',
952 'description' => 'Update attachment meta.',
953 'inputSchema' => [
954 'type' => 'object',
955 'properties' => [
956 'ID' => [ 'type' => 'integer' ],
957 'title' => [ 'type' => 'string' ],
958 'caption' => [ 'type' => 'string' ],
959 'description' => [ 'type' => 'string' ],
960 'alt' => [ 'type' => 'string' ],
961 ],
962 'required' => [ 'ID' ],
963 ],
964 'accessLevel' => 'write',
965 ],
966 'wp_delete_media' => [
967 'name' => 'wp_delete_media',
968 'description' => 'Delete/trash an attachment.',
969 'inputSchema' => [
970 'type' => 'object',
971 'properties' => [
972 'ID' => [ 'type' => 'integer' ],
973 'force' => [ 'type' => 'boolean' ],
974 ],
975 'required' => [ 'ID' ],
976 ],
977 'accessLevel' => 'admin',
978 ],
979
980 /* -------- MWAI Vision / Image -------- */
981 'mwai_vision' => [
982 'name' => 'mwai_vision',
983 'description' => 'Analyze an image via AI Engine Vision.',
984 'inputSchema' => [
985 'type' => 'object',
986 'properties' => [
987 'message' => [ 'type' => 'string' ],
988 'url' => [ 'type' => 'string' ],
989 'path' => [ 'type' => 'string' ],
990 ],
991 'required' => [ 'message' ],
992 ],
993 'accessLevel' => 'read',
994 ],
995 'mwai_image' => [
996 'name' => 'mwai_image',
997 '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 }.',
998 'inputSchema' => [
999 'type' => 'object',
1000 'properties' => [
1001 'message' => [ 'type' => 'string', 'description' => 'Prompt describing the desired image.' ],
1002 'postId' => [ 'type' => 'integer', 'description' => 'Optional post ID to attach the image to.' ],
1003 'title' => [ 'type' => 'string' ],
1004 'caption' => [ 'type' => 'string' ],
1005 'description' => [ 'type' => 'string' ],
1006 'alt' => [ 'type' => 'string' ],
1007 ],
1008 'required' => [ 'message' ],
1009 ],
1010 'accessLevel' => 'write',
1011 ],
1012
1013 ];
1014 }
1015 #endregion
1016
1017 #region Tool Registration
1018 public function register_rest_tools( array $prev ): array {
1019 $tools = $this->tools();
1020
1021 // All 36 core tools enabled and tested with ChatGPT.
1022 // Automatic validation in mcp.php fixes problematic type definitions.
1023
1024 // Add category and annotations to each tool
1025 foreach ( $tools as &$tool ) {
1026 if ( !isset( $tool['category'] ) ) {
1027 $tool['category'] = 'AI Engine (Core)';
1028 }
1029
1030 // Add MCP tool annotations based on tool name/behavior
1031 if ( !isset( $tool['annotations'] ) ) {
1032 $name = $tool['name'];
1033
1034 // Read-only tools (safe, no modifications)
1035 $is_readonly = (
1036 strpos( $name, 'wp_get_' ) === 0 ||
1037 strpos( $name, 'wp_list_' ) === 0 ||
1038 strpos( $name, 'wp_count_' ) === 0 ||
1039 $name === 'mwai_vision'
1040 );
1041
1042 // Destructive tools (can delete/destroy data)
1043 $is_destructive = (
1044 strpos( $name, 'wp_delete_' ) === 0 ||
1045 $name === 'wp_update_user' // Can change passwords/roles
1046 );
1047
1048 $tool['annotations'] = [
1049 'readOnlyHint' => $is_readonly,
1050 'destructiveHint' => !$is_readonly && $is_destructive,
1051 'openWorldHint' => false, // All operate on closed WordPress system
1052 ];
1053 }
1054 }
1055
1056 $merged = array_merge( $prev, array_values( $tools ) );
1057 return $merged;
1058 }
1059 #endregion
1060
1061 #region Callback
1062 public function handle_call( $prev, string $tool, array $args, ?int $id ) {
1063 // Security check is already done in the MCP auth layer
1064 // If we reach here, the user is authorized to use MCP
1065 if ( !empty( $prev ) || !isset( $this->tools()[ $tool ] ) ) {
1066 return $prev;
1067 }
1068 return $this->dispatch( $tool, $args, $id );
1069 }
1070 #endregion
1071
1072 #region Dispatcher
1073 private function dispatch( string $tool, array $a, ?int $id ): array {
1074 $r = [ 'jsonrpc' => '2.0', 'id' => $id ];
1075
1076 // Accept common aliases for the primary record id. The post tools use the
1077 // WordPress-native "ID" (matching wp_update_post() / $post->ID), while
1078 // wp_set_featured_image, the comment tools, and the SEO/Woo suites use
1079 // "post_id". Agents hopping between tools guess the wrong spelling and hit a
1080 // bare "ID required". No tool in this suite uses two of these keys to mean
1081 // two different things, so mirroring them is safe; each handler still reads
1082 // its own canonical key.
1083 $idAliases = [ 'ID', 'post_id', 'id' ];
1084 $primaryId = null;
1085 foreach ( $idAliases as $k ) {
1086 if ( isset( $a[ $k ] ) && $a[ $k ] !== '' ) {
1087 $primaryId = $a[ $k ];
1088 break;
1089 }
1090 }
1091 if ( $primaryId !== null ) {
1092 foreach ( $idAliases as $k ) {
1093 if ( !isset( $a[ $k ] ) || $a[ $k ] === '' ) {
1094 $a[ $k ] = $primaryId;
1095 }
1096 }
1097 }
1098
1099 switch ( $tool ) {
1100
1101 /* ===== Users ===== */
1102 case 'wp_get_users':
1103 $q = [
1104 'search' => '*' . esc_attr( $a['search'] ?? '' ) . '*',
1105 'role' => $a['role'] ?? '',
1106 'number' => max( 1, intval( $a['limit'] ?? 10 ) ),
1107 ];
1108 if ( isset( $a['offset'] ) ) {
1109 $q['offset'] = max( 0, intval( $a['offset'] ) );
1110 }
1111 if ( isset( $a['paged'] ) ) {
1112 $q['paged'] = max( 1, intval( $a['paged'] ) );
1113 }
1114 $rows = [];
1115 foreach ( get_users( $q ) as $u ) {
1116 $rows[] = [
1117 'ID' => $u->ID,
1118 'user_login' => $u->user_login,
1119 'display_name' => $u->display_name,
1120 'roles' => $u->roles,
1121 ];
1122 }
1123 $this->add_result_text( $r, wp_json_encode( $rows, JSON_PRETTY_PRINT ) );
1124 break;
1125
1126 case 'wp_create_user':
1127 // Same object-level gap as wp_update_user: the MCP gate only checks the
1128 // administrator role. wp_insert_user() runs no capability checks, so
1129 // require create_users (which on Multisite is a network-only capability,
1130 // correctly denying per-site Administrators) and refuse to assign a role
1131 // the caller cannot grant (e.g. administrator).
1132 if ( !current_user_can( 'create_users' ) ) {
1133 $r['error'] = [ 'code' => -32603, 'message' => 'You are not allowed to create users.' ];
1134 break;
1135 }
1136 $role = sanitize_key( $a['role'] ?? get_option( 'default_role', 'subscriber' ) );
1137 require_once ABSPATH . 'wp-admin/includes/user.php'; // get_editable_roles()
1138 if ( $role !== '' && !array_key_exists( $role, get_editable_roles() ) ) {
1139 $r['error'] = [ 'code' => -32603, 'message' => 'You are not allowed to assign this role.' ];
1140 break;
1141 }
1142 $data = [
1143 'user_login' => sanitize_user( $a['user_login'] ),
1144 'user_email' => sanitize_email( $a['user_email'] ),
1145 'user_pass' => $a['user_pass'] ?? wp_generate_password( 12, true ),
1146 'display_name' => sanitize_text_field( $a['display_name'] ?? '' ),
1147 'role' => $role,
1148 ];
1149 $uid = wp_insert_user( $data );
1150 if ( is_wp_error( $uid ) ) {
1151 $r['error'] = [ 'code' => $uid->get_error_code(), 'message' => $uid->get_error_message() ];
1152 }
1153 else {
1154 $this->add_result_text( $r, 'User created ID ' . $uid );
1155 }
1156 break;
1157
1158 case 'wp_update_user':
1159 if ( empty( $a['ID'] ) ) {
1160 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
1161 break;
1162 }
1163 $target_id = intval( $a['ID'] );
1164 // Object-level authorization. The MCP gate only checks that the caller
1165 // holds the administrator role, not that they may touch THIS user.
1166 // wp_update_user() runs no capability checks of its own, so without this
1167 // a Multisite per-site Administrator could edit users they cannot touch
1168 // in wp-admin (e.g. set a new password on the Network Owner). Delegating
1169 // to edit_user enforces the same boundary core does, for every auth path.
1170 // Reported by Charles Vosburgh via responsible disclosure.
1171 if ( !current_user_can( 'edit_user', $target_id ) ) {
1172 $r['error'] = [ 'code' => -32603, 'message' => 'You are not allowed to edit this user.' ];
1173 break;
1174 }
1175 $upd = [ 'ID' => $target_id ];
1176 if ( !empty( $a['fields'] ) && is_array( $a['fields'] ) ) {
1177 foreach ( $a['fields'] as $k => $v ) {
1178 $upd[ $k ] = ( $k === 'role' ) ? sanitize_key( $v ) : sanitize_text_field( $v );
1179 }
1180 }
1181 // A role change is a promotion/demotion. Require promote_user on the
1182 // target and refuse any role the caller cannot themselves assign, so a
1183 // lower admin cannot grant a role above their own reach.
1184 if ( isset( $upd['role'] ) && $upd['role'] !== '' ) {
1185 require_once ABSPATH . 'wp-admin/includes/user.php'; // get_editable_roles()
1186 if ( !current_user_can( 'promote_user', $target_id ) || !array_key_exists( $upd['role'], get_editable_roles() ) ) {
1187 $r['error'] = [ 'code' => -32603, 'message' => 'You are not allowed to assign this role.' ];
1188 break;
1189 }
1190 }
1191 $u = wp_update_user( $upd );
1192 if ( is_wp_error( $u ) ) {
1193 $r['error'] = [ 'code' => $u->get_error_code(), 'message' => $u->get_error_message() ];
1194 }
1195 else {
1196 $this->add_result_text( $r, 'User #' . $u . ' updated' );
1197 }
1198 break;
1199
1200 /* ===== Comments ===== */
1201 case 'wp_get_comments':
1202 $args = [
1203 'post_id' => isset( $a['post_id'] ) ? intval( $a['post_id'] ) : '',
1204 'status' => $a['status'] ?? 'approve',
1205 'search' => $a['search'] ?? '',
1206 'number' => max( 1, intval( $a['limit'] ?? 10 ) ),
1207 ];
1208 // WP 6.9 Notes are comments with comment_type 'note'. Filter by type when
1209 // asked (unset = all types, preserving prior behavior). Notes track their
1210 // state via comment_status (hold = open, approve = resolved), so when
1211 // reading notes without an explicit status, return all statuses; otherwise
1212 // the 'approve' default would hide every open note.
1213 if ( isset( $a['type'] ) && $a['type'] !== '' ) {
1214 $args['type'] = sanitize_key( $a['type'] );
1215 if ( $args['type'] === 'note' && !isset( $a['status'] ) ) {
1216 $args['status'] = 'all';
1217 }
1218 }
1219 if ( isset( $a['user_id'] ) ) {
1220 $args['user_id'] = intval( $a['user_id'] );
1221 }
1222 if ( $a['author_email'] ?? '' ) {
1223 $args['author_email'] = sanitize_email( $a['author_email'] );
1224 }
1225 if ( isset( $a['offset'] ) ) {
1226 $args['offset'] = max( 0, intval( $a['offset'] ) );
1227 }
1228 if ( isset( $a['paged'] ) ) {
1229 $args['paged'] = max( 1, intval( $a['paged'] ) );
1230 }
1231 $list = [];
1232 foreach ( get_comments( $args ) as $c ) {
1233 $list[] = [
1234 'comment_ID' => $c->comment_ID,
1235 'comment_post_ID' => $c->comment_post_ID,
1236 'comment_type' => $c->comment_type,
1237 'comment_author' => $c->comment_author,
1238 'comment_content' => wp_trim_words( wp_strip_all_tags( $c->comment_content ), 40 ),
1239 'comment_date' => $c->comment_date,
1240 'comment_approved' => $c->comment_approved,
1241 ];
1242 }
1243 $this->add_result_text( $r, wp_json_encode( $list, JSON_PRETTY_PRINT ) );
1244 break;
1245
1246 case 'wp_create_comment':
1247 if ( empty( $a['post_id'] ) || empty( $a['comment_content'] ) ) {
1248 $r['error'] = [ 'code' => -32602, 'message' => 'post_id & comment_content required' ];
1249 break;
1250 }
1251 $ins = [
1252 'comment_post_ID' => intval( $a['post_id'] ),
1253 'comment_content' => $this->clean_html( $a['comment_content'] ),
1254 'comment_author' => sanitize_text_field( $a['comment_author'] ?? '' ),
1255 'comment_author_email' => sanitize_email( $a['comment_author_email'] ?? '' ),
1256 'comment_author_url' => esc_url_raw( $a['comment_author_url'] ?? '' ),
1257 'comment_approved' => $a['comment_approved'] ?? 1,
1258 ];
1259 $cid = wp_insert_comment( $ins );
1260 if ( is_wp_error( $cid ) ) {
1261 /** @var WP_Error $cid */
1262 $r['error'] = [ 'code' => $cid->get_error_code(), 'message' => $cid->get_error_message() ];
1263 }
1264 else {
1265 $this->add_result_text( $r, 'Comment created ID ' . $cid );
1266 }
1267 break;
1268
1269 case 'wp_update_comment':
1270 if ( empty( $a['comment_ID'] ) ) {
1271 $r['error'] = [ 'code' => -32602, 'message' => 'comment_ID required' ];
1272 break;
1273 }
1274 $c = [ 'comment_ID' => intval( $a['comment_ID'] ) ];
1275 if ( !empty( $a['fields'] ) && is_array( $a['fields'] ) ) {
1276 foreach ( $a['fields'] as $k => $v ) {
1277 $c[ $k ] = ( $k === 'comment_content' ) ? $this->clean_html( $v ) : sanitize_text_field( $v );
1278 }
1279 }
1280 $cid = wp_update_comment( $c, true );
1281 if ( is_wp_error( $cid ) ) {
1282 $r['error'] = [ 'code' => $cid->get_error_code(), 'message' => $cid->get_error_message() ];
1283 }
1284 else {
1285 $this->add_result_text( $r, 'Comment #' . $cid . ' updated' );
1286 }
1287 break;
1288
1289 case 'wp_delete_comment':
1290 if ( empty( $a['comment_ID'] ) ) {
1291 $r['error'] = [ 'code' => -32602, 'message' => 'comment_ID required' ];
1292 break;
1293 }
1294 $done = wp_delete_comment( intval( $a['comment_ID'] ), !empty( $a['force'] ) );
1295 if ( $done ) {
1296 $this->add_result_text( $r, 'Comment #' . $a['comment_ID'] . ' deleted' );
1297 }
1298 else {
1299 $r['error'] = [ 'code' => -32603, 'message' => 'Deletion failed' ];
1300 }
1301 break;
1302
1303 /* ===== Options ===== */
1304 case 'wp_get_option':
1305 $opt_key = sanitize_key( $a['key'] );
1306 if ( !empty( $a['raw'] ) ) {
1307 // Read straight from the DB so neither the object cache nor an
1308 // option_* filter can mask the stored value. Mirrors what `wp-cli
1309 // option get` returns under CLI (where front-end filters aren't loaded).
1310 global $wpdb;
1311 $stored = $wpdb->get_var( $wpdb->prepare(
1312 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
1313 $opt_key
1314 ) );
1315 $val = is_null( $stored ) ? false : maybe_unserialize( $stored );
1316 }
1317 else {
1318 $val = get_option( $opt_key );
1319 }
1320 $this->add_result_text( $r, wp_json_encode( $val, JSON_PRETTY_PRINT ) );
1321 break;
1322
1323 case 'wp_update_option':
1324 $value = $a['value'];
1325 // MCP clients commonly send array/object option values as a JSON string.
1326 // Decode them back to native PHP arrays before writing: storing the raw
1327 // JSON string for an array option (e.g. sticky_posts) corrupts it and can
1328 // fatal hooks that expect an array (Polylang's sync_sticky_posts runs
1329 // array_diff on it). Scalars and plain strings are left untouched.
1330 if ( is_string( $value ) && isset( $value[0] ) && ( $value[0] === '[' || $value[0] === '{' ) ) {
1331 $decoded = json_decode( $value, true );
1332 if ( json_last_error() === JSON_ERROR_NONE && is_array( $decoded ) ) {
1333 $value = $decoded;
1334 }
1335 }
1336 $set = update_option( sanitize_key( $a['key'] ), $value, 'yes' );
1337 if ( $set ) {
1338 $this->add_result_text( $r, 'Option "' . $a['key'] . '" updated' );
1339 }
1340 else {
1341 $r['error'] = [ 'code' => -32603, 'message' => 'Update failed' ];
1342 }
1343 break;
1344
1345 /* ===== Counts ===== */
1346 case 'wp_count_posts':
1347 $pt = sanitize_key( $a['post_type'] ?? 'post' );
1348 $obj = wp_count_posts( $pt );
1349 $this->add_result_text( $r, wp_json_encode( $obj, JSON_PRETTY_PRINT ) );
1350 break;
1351
1352 case 'wp_count_terms':
1353 $tax = sanitize_key( $a['taxonomy'] );
1354 $total = wp_count_terms( $tax, [ 'hide_empty' => false ] );
1355 if ( is_wp_error( $total ) ) {
1356 $r['error'] = [ 'code' => $total->get_error_code(), 'message' => $total->get_error_message() ];
1357 }
1358 else {
1359 $this->add_result_text( $r, (string) $total );
1360 }
1361 break;
1362
1363 case 'wp_count_media':
1364 $args = [ 'post_type' => 'attachment', 'post_status' => 'inherit', 'fields' => 'ids' ];
1365 $d = [];
1366 if ( $a['after'] ?? '' ) {
1367 $d['after'] = $a['after'];
1368 }
1369 if ( $a['before'] ?? '' ) {
1370 $d['before'] = $a['before'];
1371 }
1372 if ( $d ) {
1373 $args['date_query'] = [ $d ];
1374 }
1375 $total = count( get_posts( $args ) );
1376 $this->add_result_text( $r, (string) $total );
1377 break;
1378
1379 /* ===== Post-types ===== */
1380 case 'wp_get_post_types':
1381 $out = [];
1382 foreach ( get_post_types( [ 'public' => true ], 'objects' ) as $pt ) {
1383 $out[] = [ 'key' => $pt->name, 'label' => $pt->label ];
1384 }
1385 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
1386 break;
1387
1388 /* ===== Plugins ===== */
1389 case 'wp_list_plugins':
1390 if ( !function_exists( 'get_plugins' ) ) {
1391 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1392 }
1393 $search = sanitize_text_field( $a['search'] ?? '' );
1394 $out = [];
1395 foreach ( get_plugins() as $p ) {
1396 if ( !$search || stripos( $p['Name'], $search ) !== false ) {
1397 $out[] = [ 'Name' => $p['Name'], 'Version' => $p['Version'] ];
1398 }
1399 }
1400 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
1401 break;
1402
1403 /* ===== Posts: list ===== */
1404 case 'wp_get_posts':
1405 $q = [
1406 'post_type' => sanitize_key( $a['post_type'] ?? 'post' ),
1407 'post_status' => sanitize_key( $a['post_status'] ?? 'publish' ),
1408 's' => sanitize_text_field( $a['search'] ?? '' ),
1409 'posts_per_page' => max( 1, intval( $a['limit'] ?? 10 ) ),
1410 ];
1411 if ( isset( $a['offset'] ) ) {
1412 $q['offset'] = max( 0, intval( $a['offset'] ) );
1413 }
1414 if ( isset( $a['paged'] ) ) {
1415 $q['paged'] = max( 1, intval( $a['paged'] ) );
1416 }
1417 if ( isset( $a['author'] ) ) {
1418 $q['author'] = intval( $a['author'] );
1419 }
1420 elseif ( $a['author_name'] ?? '' ) {
1421 $q['author_name'] = sanitize_title( $a['author_name'] );
1422 }
1423 if ( !empty( $a['author__not_in'] ) && is_array( $a['author__not_in'] ) ) {
1424 $q['author__not_in'] = array_map( 'intval', $a['author__not_in'] );
1425 }
1426 $date = [];
1427 if ( $a['after'] ?? '' ) {
1428 $date['after'] = $a['after'];
1429 }
1430 if ( $a['before'] ?? '' ) {
1431 $date['before'] = $a['before'];
1432 }
1433 if ( $date ) {
1434 $q['date_query'] = [ $date ];
1435 }
1436 $rows = [];
1437 foreach ( get_posts( $q ) as $p ) {
1438 $rows[] = [
1439 'ID' => $p->ID,
1440 'post_title' => $p->post_title,
1441 'post_status' => $p->post_status,
1442 'post_excerpt' => $this->post_excerpt( $p ),
1443 'permalink' => get_permalink( $p ),
1444 ];
1445 }
1446 $this->add_result_text( $r, wp_json_encode( $rows, JSON_PRETTY_PRINT ) );
1447 break;
1448
1449 /* ===== Posts: single ===== */
1450 case 'wp_get_post':
1451 if ( empty( $a['ID'] ) ) {
1452 $r['error'] = [ 'code' => -32602, 'message' => 'Post ID required (pass "ID", e.g. {"ID": 123}; "post_id" is also accepted).' ];
1453 break;
1454 }
1455 $p = get_post( intval( $a['ID'] ) );
1456 if ( !$p ) {
1457 $r['error'] = [ 'code' => -32602, 'message' => 'Post not found' ];
1458 break;
1459 }
1460 $out = [
1461 'ID' => $p->ID,
1462 'post_title' => $p->post_title,
1463 'post_status' => $p->post_status,
1464 'post_content' => ( ( $a['content_format'] ?? 'full' ) === 'prose' )
1465 ? $this->prose_content( $p->post_content )
1466 : $this->read_html( $p->post_content ),
1467 'post_excerpt' => $this->post_excerpt( $p ),
1468 'permalink' => get_permalink( $p ),
1469 'post_date' => $p->post_date,
1470 'post_modified' => $p->post_modified,
1471 ];
1472 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
1473 break;
1474
1475 /* ===== Posts: snapshot ===== */
1476 case 'wp_get_post_snapshot':
1477 if ( empty( $a['ID'] ) ) {
1478 $r['error'] = [ 'code' => -32602, 'message' => 'Post ID required (pass "ID", e.g. {"ID": 123}; "post_id" is also accepted).' ];
1479 break;
1480 }
1481
1482 $post_id = intval( $a['ID'] );
1483 $p = get_post( $post_id );
1484
1485 if ( !$p ) {
1486 $r['error'] = [ 'code' => -32602, 'message' => 'Post not found' ];
1487 break;
1488 }
1489
1490 $include = $a['include'] ?? [ 'meta', 'terms', 'thumbnail', 'author' ];
1491 $exclude = $a['exclude'] ?? [];
1492
1493 // Handle JSON strings (some MCP clients send arrays as JSON strings)
1494 if ( is_string( $include ) ) {
1495 $include = json_decode( $include, true ) ?? [];
1496 }
1497 if ( is_string( $exclude ) ) {
1498 $exclude = json_decode( $exclude, true ) ?? [];
1499 }
1500
1501 $snapshot = [
1502 'post' => [
1503 'ID' => $p->ID,
1504 'post_title' => $p->post_title,
1505 'post_type' => $p->post_type,
1506 'post_status' => $p->post_status,
1507 'post_excerpt' => $this->post_excerpt( $p ),
1508 'post_name' => $p->post_name,
1509 'permalink' => get_permalink( $p ),
1510 'post_date' => $p->post_date,
1511 'post_modified' => $p->post_modified,
1512 ],
1513 ];
1514
1515 // Include content unless excluded (useful for posts with huge content)
1516 if ( !in_array( 'content', $exclude ) ) {
1517 $snapshot['post']['post_content'] = ( ( $a['content_format'] ?? 'full' ) === 'prose' )
1518 ? $this->prose_content( $p->post_content )
1519 : $this->read_html( $p->post_content );
1520 }
1521
1522 // Include all post meta
1523 if ( in_array( 'meta', $include ) ) {
1524 $snapshot['meta'] = [];
1525 $all_meta = get_post_meta( $post_id );
1526 foreach ( $all_meta as $key => $value ) {
1527 if ( is_array( $value ) && count( $value ) === 1 ) {
1528 $snapshot['meta'][ $key ] = maybe_unserialize( $value[0] );
1529 }
1530 else {
1531 $snapshot['meta'][ $key ] = array_map( 'maybe_unserialize', $value );
1532 }
1533 }
1534 }
1535
1536 // Include all taxonomies and their terms
1537 if ( in_array( 'terms', $include ) ) {
1538 $snapshot['terms'] = [];
1539 $taxonomies = get_object_taxonomies( $p->post_type );
1540 foreach ( $taxonomies as $taxonomy ) {
1541 $terms = wp_get_post_terms( $post_id, $taxonomy, [ 'fields' => 'all' ] );
1542 if ( !is_wp_error( $terms ) && !empty( $terms ) ) {
1543 $snapshot['terms'][ $taxonomy ] = array_map( function ( $t ) {
1544 return [
1545 'term_id' => $t->term_id,
1546 'name' => $t->name,
1547 'slug' => $t->slug,
1548 ];
1549 }, $terms );
1550 }
1551 }
1552 }
1553
1554 // Include featured image
1555 if ( in_array( 'thumbnail', $include ) ) {
1556 $thumb_id = get_post_thumbnail_id( $post_id );
1557 if ( $thumb_id ) {
1558 $snapshot['thumbnail'] = [
1559 'ID' => $thumb_id,
1560 'url' => wp_get_attachment_url( $thumb_id ),
1561 'alt' => get_post_meta( $thumb_id, '_wp_attachment_image_alt', true ),
1562 ];
1563 }
1564 }
1565
1566 // Include author
1567 if ( in_array( 'author', $include ) ) {
1568 $author = get_userdata( $p->post_author );
1569 if ( $author ) {
1570 $snapshot['author'] = [
1571 'ID' => $author->ID,
1572 'display_name' => $author->display_name,
1573 'user_login' => $author->user_login,
1574 ];
1575 }
1576 }
1577
1578 $this->add_result_text( $r, wp_json_encode( $snapshot, JSON_PRETTY_PRINT ) );
1579 break;
1580
1581 /* ===== Posts: create ===== */
1582 case 'wp_create_post':
1583 if ( empty( $a['post_title'] ) ) {
1584 $r['error'] = [ 'code' => -32602, 'message' => 'post_title required' ];
1585 break;
1586 }
1587 $ins = [
1588 'post_title' => sanitize_text_field( $a['post_title'] ),
1589 'post_status' => sanitize_key( $a['post_status'] ?? 'draft' ),
1590 'post_type' => sanitize_key( $a['post_type'] ?? 'post' ),
1591 ];
1592 if ( $a['post_content'] ?? '' ) {
1593 $ins['post_content'] = $this->prepare_new_content( $a['post_content'] );
1594 }
1595 if ( $a['post_excerpt'] ?? '' ) {
1596 $ins['post_excerpt'] = $this->clean_html( $a['post_excerpt'] );
1597 }
1598 if ( $a['post_name'] ?? '' ) {
1599 $ins['post_name'] = sanitize_title( $a['post_name'] );
1600 }
1601
1602 // Handle JSON strings for meta_input (some MCP clients send objects as JSON strings)
1603 $meta_input = $a['meta_input'] ?? [];
1604 if ( is_string( $meta_input ) ) {
1605 $meta_input = json_decode( $meta_input, true ) ?? [];
1606 }
1607 if ( !empty( $meta_input ) && is_array( $meta_input ) ) {
1608 $ins['meta_input'] = $meta_input;
1609 }
1610
1611 $new = wp_insert_post( wp_slash( $ins ), true );
1612 if ( is_wp_error( $new ) ) {
1613 $r['error'] = [ 'code' => $new->get_error_code(), 'message' => $new->get_error_message() ];
1614 }
1615 else {
1616 if ( empty( $ins['meta_input'] ) && !empty( $meta_input ) && is_array( $meta_input ) ) {
1617 foreach ( $meta_input as $k => $v ) {
1618 // Pass the value as-is: update_post_meta() serializes arrays itself.
1619 // maybe_serialize() here double-serialized nested arrays, so they read
1620 // back as a string and consumers (e.g. Noptin) rejected them as legacy.
1621 update_post_meta( $new, sanitize_key( $k ), $v );
1622 }
1623 }
1624 $this->bust_post_cache( (int) $new, [ 'tool' => 'wp_create_post' ] );
1625 $this->add_result_text( $r, 'Post created ID ' . $new );
1626 }
1627 break;
1628
1629 /* ===== Posts: write blocks ===== */
1630 case 'wp_write_blocks':
1631 if ( empty( $a['ID'] ) ) {
1632 $r['error'] = [ 'code' => -32602, 'message' => 'Post ID required (pass "ID"; create the post first with wp_create_post).' ];
1633 break;
1634 }
1635 $wb_id = intval( $a['ID'] );
1636 $wb_post = get_post( $wb_id );
1637 if ( !$wb_post ) {
1638 $r['error'] = [ 'code' => -32602, 'message' => 'Post ' . $wb_id . ' not found.' ];
1639 break;
1640 }
1641 // Some MCP clients send arrays as JSON strings.
1642 $wb_blocks = $a['blocks'] ?? null;
1643 if ( is_string( $wb_blocks ) ) {
1644 $wb_blocks = json_decode( $wb_blocks, true );
1645 }
1646 list( $wb_markup, $wb_err ) = $this->blocks_to_markup( $wb_blocks );
1647 if ( $wb_err !== null ) {
1648 $r['error'] = [ 'code' => -32602, 'message' => $wb_err ];
1649 break;
1650 }
1651 $wb_mode = in_array( $a['mode'] ?? 'replace', [ 'replace', 'append', 'prepend' ], true ) ? ( $a['mode'] ?? 'replace' ) : 'replace';
1652 if ( $wb_mode === 'append' ) {
1653 $wb_content = trim( $wb_post->post_content . "\n\n" . $wb_markup );
1654 }
1655 elseif ( $wb_mode === 'prepend' ) {
1656 $wb_content = trim( $wb_markup . "\n\n" . $wb_post->post_content );
1657 }
1658 else {
1659 $wb_content = $wb_markup;
1660 }
1661 $wb_res = wp_update_post( wp_slash( [ 'ID' => $wb_id, 'post_content' => $wb_content ] ), true );
1662 if ( is_wp_error( $wb_res ) ) {
1663 $r['error'] = [ 'code' => $wb_res->get_error_code(), 'message' => $wb_res->get_error_message() ];
1664 break;
1665 }
1666 $this->bust_post_cache( $wb_id, [ 'tool' => 'wp_write_blocks' ] );
1667 $this->add_result_text( $r, 'Wrote ' . count( $wb_blocks ) . ' block(s) to post ' . $wb_id . ' (mode: ' . $wb_mode . ').' );
1668 break;
1669
1670 /* ===== Block patterns: list ===== */
1671 case 'wp_list_block_patterns':
1672 if ( !class_exists( 'WP_Block_Patterns_Registry' ) ) {
1673 $r['error'] = [ 'code' => -32603, 'message' => 'Block patterns are not available on this site.' ];
1674 break;
1675 }
1676 $bp_all = WP_Block_Patterns_Registry::get_instance()->get_all_registered();
1677 $bp_search = isset( $a['search'] ) ? strtolower( trim( (string) $a['search'] ) ) : '';
1678 $bp_cat = isset( $a['category'] ) ? sanitize_title( $a['category'] ) : '';
1679 $bp_content = !empty( $a['include_content'] );
1680 $bp_limit = isset( $a['limit'] ) ? max( 1, min( 500, (int) $a['limit'] ) ) : 50;
1681 $bp_list = [];
1682 foreach ( $bp_all as $pat ) {
1683 $cats = (array) ( $pat['categories'] ?? [] );
1684 if ( $bp_cat !== '' && !in_array( $bp_cat, array_map( 'sanitize_title', $cats ), true ) ) {
1685 continue;
1686 }
1687 if ( $bp_search !== '' ) {
1688 $hay = strtolower( ( $pat['title'] ?? '' ) . ' ' . ( $pat['name'] ?? '' ) . ' ' . ( $pat['description'] ?? '' ) . ' ' . implode( ' ', (array) ( $pat['keywords'] ?? [] ) ) );
1689 if ( strpos( $hay, $bp_search ) === false ) {
1690 continue;
1691 }
1692 }
1693 $entry = [
1694 'name' => $pat['name'] ?? '',
1695 'title' => $pat['title'] ?? '',
1696 'categories' => array_values( $cats ),
1697 'description' => $pat['description'] ?? '',
1698 ];
1699 if ( $bp_content ) {
1700 $entry['content'] = $pat['content'] ?? '';
1701 }
1702 $bp_list[] = $entry;
1703 if ( count( $bp_list ) >= $bp_limit ) {
1704 break;
1705 }
1706 }
1707 $this->add_result_text( $r, wp_json_encode( [ 'count' => count( $bp_list ), 'total_registered' => count( $bp_all ), 'patterns' => $bp_list ], JSON_PRETTY_PRINT ) );
1708 break;
1709
1710 /* ===== Block patterns: insert ===== */
1711 case 'wp_insert_block_pattern':
1712 if ( empty( $a['ID'] ) || empty( $a['pattern'] ) ) {
1713 $r['error'] = [ 'code' => -32602, 'message' => 'Both "ID" and "pattern" (a name from wp_list_block_patterns) are required.' ];
1714 break;
1715 }
1716 if ( !class_exists( 'WP_Block_Patterns_Registry' ) ) {
1717 $r['error'] = [ 'code' => -32603, 'message' => 'Block patterns are not available on this site.' ];
1718 break;
1719 }
1720 $bp_name = sanitize_text_field( $a['pattern'] );
1721 $bp_reg = WP_Block_Patterns_Registry::get_instance();
1722 if ( !$bp_reg->is_registered( $bp_name ) ) {
1723 $r['error'] = [ 'code' => -32602, 'message' => 'Pattern "' . $bp_name . '" is not registered. Use wp_list_block_patterns to see available names.' ];
1724 break;
1725 }
1726 $bp_pat = $bp_reg->get_registered( $bp_name );
1727 $bp_markup = (string) ( $bp_pat['content'] ?? '' );
1728 if ( $bp_markup === '' ) {
1729 $r['error'] = [ 'code' => -32603, 'message' => 'Pattern "' . $bp_name . '" has no content.' ];
1730 break;
1731 }
1732 $bp_id = intval( $a['ID'] );
1733 $bp_post = get_post( $bp_id );
1734 if ( !$bp_post ) {
1735 $r['error'] = [ 'code' => -32602, 'message' => 'Post ' . $bp_id . ' not found.' ];
1736 break;
1737 }
1738 $bp_mode = in_array( $a['mode'] ?? 'append', [ 'replace', 'append', 'prepend' ], true ) ? ( $a['mode'] ?? 'append' ) : 'append';
1739 if ( $bp_mode === 'replace' ) {
1740 $bp_new = $bp_markup;
1741 }
1742 elseif ( $bp_mode === 'prepend' ) {
1743 $bp_new = trim( $bp_markup . "\n\n" . $bp_post->post_content );
1744 }
1745 else {
1746 $bp_new = trim( $bp_post->post_content . "\n\n" . $bp_markup );
1747 }
1748 $bp_res = wp_update_post( wp_slash( [ 'ID' => $bp_id, 'post_content' => $bp_new ] ), true );
1749 if ( is_wp_error( $bp_res ) ) {
1750 $r['error'] = [ 'code' => $bp_res->get_error_code(), 'message' => $bp_res->get_error_message() ];
1751 break;
1752 }
1753 $this->bust_post_cache( $bp_id, [ 'tool' => 'wp_insert_block_pattern' ] );
1754 $this->add_result_text( $r, 'Inserted pattern "' . $bp_name . '" into post ' . $bp_id . ' (mode: ' . $bp_mode . ').' );
1755 break;
1756
1757 /* ===== Posts: update ===== */
1758 case 'wp_update_post':
1759 if ( empty( $a['ID'] ) ) {
1760 $r['error'] = [ 'code' => -32602, 'message' => 'Post ID required (pass "ID", e.g. {"ID": 123}; "post_id" is also accepted).' ];
1761 break;
1762 }
1763 $post_id = intval( $a['ID'] );
1764 $c = [ 'ID' => $post_id ];
1765
1766 // Handle JSON strings (some MCP clients send objects as JSON strings)
1767 $fields_raw = $a['fields'] ?? null;
1768 $fields = $fields_raw;
1769 if ( is_string( $fields ) ) {
1770 $fields = json_decode( $fields, true );
1771 // Detect truncated/malformed JSON
1772 if ( $fields === null && strlen( $fields_raw ) > 0 ) {
1773 $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' ];
1774 break;
1775 }
1776 }
1777 $fields = $fields ?? [];
1778 if ( !is_array( $fields ) ) {
1779 $fields = [];
1780 }
1781
1782 // Convenience: also accept post fields passed at the top level instead of
1783 // nested in "fields". Agents routinely send { ID, post_title } directly and
1784 // would otherwise get a misleading "no fields provided" error. Nested
1785 // values win on conflict.
1786 $topLevelFields = [ 'post_title', 'post_content', 'post_status', 'post_name',
1787 'post_excerpt', 'post_category', 'post_type', 'post_author', 'post_parent',
1788 'post_date', 'menu_order', 'comment_status', 'ping_status', 'page_template' ];
1789 foreach ( $topLevelFields as $fk ) {
1790 if ( array_key_exists( $fk, $a ) && !array_key_exists( $fk, $fields ) ) {
1791 $fields[ $fk ] = $a[ $fk ];
1792 }
1793 }
1794
1795 // Track what we're trying to update for verification
1796 $content_to_verify = null;
1797 if ( !empty( $fields ) && is_array( $fields ) ) {
1798 foreach ( $fields as $k => $v ) {
1799 $c[ $k ] = in_array( $k, [ 'post_content', 'post_excerpt' ], true ) ? $this->store_html( $v ) : sanitize_text_field( $v );
1800 }
1801 if ( isset( $c['post_content'] ) ) {
1802 $content_to_verify = $c['post_content'];
1803 }
1804 }
1805
1806 // Handle schedule_for convenience parameter
1807 if ( !empty( $a['schedule_for'] ) ) {
1808 $schedule_date = sanitize_text_field( $a['schedule_for'] );
1809 $c['post_status'] = 'future';
1810 $c['post_date'] = $schedule_date;
1811 $c['post_date_gmt'] = get_gmt_from_date( $schedule_date );
1812 $c['edit_date'] = true; // Required for WordPress to respect date changes
1813 }
1814
1815 // Handle JSON strings for meta_input
1816 $meta_raw = $a['meta_input'] ?? null;
1817 $meta_input = $meta_raw;
1818 if ( is_string( $meta_input ) ) {
1819 $meta_input = json_decode( $meta_input, true );
1820 if ( $meta_input === null && strlen( $meta_raw ) > 0 ) {
1821 $r['error'] = [ 'code' => -32602, 'message' => 'meta_input parameter is invalid JSON (possibly truncated).' ];
1822 break;
1823 }
1824 }
1825 $meta_input = $meta_input ?? [];
1826 $has_meta = !empty( $meta_input ) && is_array( $meta_input );
1827 $has_fields = count( $c ) > 1;
1828
1829 // Error if nothing to update
1830 if ( !$has_fields && !$has_meta ) {
1831 $hint = '';
1832 if ( isset( $a['fields'] ) || isset( $a['meta_input'] ) ) {
1833 $hint = ' (parameters were provided but parsed as empty - check for malformed JSON)';
1834 }
1835 $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 ];
1836 break;
1837 }
1838
1839 // Detect trash / untrash transitions and route through wp_trash_post() /
1840 // wp_untrash_post() so the proper hooks fire (ACF cleanup, search-index purges,
1841 // SEO plugins, etc.). A bare wp_update_post( ['post_status' => 'trash'] ) just
1842 // flips the status field and skips all of that.
1843 $u = $post_id;
1844 if ( isset( $c['post_status'] ) ) {
1845 $current = get_post( $post_id );
1846 $current_status = $current ? $current->post_status : null;
1847 $target_status = $c['post_status'];
1848
1849 if ( $target_status === 'trash' && $current_status !== 'trash' ) {
1850 $trashed = wp_trash_post( $post_id );
1851 if ( !$trashed ) {
1852 $r['error'] = [ 'code' => -32603, 'message' => 'wp_trash_post failed' ];
1853 break;
1854 }
1855 unset( $c['post_status'] );
1856 $has_fields = count( $c ) > 1;
1857 }
1858 elseif ( $current_status === 'trash' && $target_status !== 'trash' ) {
1859 $untrashed = wp_untrash_post( $post_id );
1860 if ( !$untrashed ) {
1861 $r['error'] = [ 'code' => -32603, 'message' => 'wp_untrash_post failed' ];
1862 break;
1863 }
1864 // Leave post_status in $c: wp_untrash_post restores to a previous status, and
1865 // a subsequent wp_update_post() will set the explicit one the caller asked for.
1866 }
1867 }
1868
1869 // Update post fields if any
1870 if ( $has_fields ) {
1871 $u = wp_update_post( wp_slash( $c ), true );
1872 if ( is_wp_error( $u ) ) {
1873 $r['error'] = [ 'code' => $u->get_error_code(), 'message' => $u->get_error_message() ];
1874 break;
1875 }
1876 }
1877
1878 // Update meta if any
1879 if ( $has_meta ) {
1880 foreach ( $meta_input as $k => $v ) {
1881 // Pass the value as-is: update_post_meta() serializes arrays itself.
1882 // maybe_serialize() here double-serialized nested arrays.
1883 update_post_meta( $u, sanitize_key( $k ), $v );
1884 }
1885 }
1886
1887 $this->bust_post_cache( (int) $u, [ 'tool' => 'wp_update_post' ] );
1888
1889 // Verify the update actually took effect
1890 $updated_post = get_post( $u );
1891 $result = [
1892 'post_id' => $u,
1893 'post_modified' => $updated_post->post_modified,
1894 ];
1895
1896 // Verify content was saved correctly if we tried to update it
1897 if ( $content_to_verify !== null ) {
1898 $saved_content = $updated_post->post_content;
1899 $result['content_length'] = strlen( $saved_content );
1900 if ( $saved_content !== $content_to_verify ) {
1901 $result['warning'] = 'Content differs from input (sanitization applied or save failed)';
1902 $result['expected_length'] = strlen( $content_to_verify );
1903 }
1904 }
1905
1906 if ( !empty( $a['schedule_for'] ) ) {
1907 $result['scheduled_for'] = $a['schedule_for'];
1908 }
1909
1910 $this->add_result_text( $r, wp_json_encode( $result, JSON_PRETTY_PRINT ) );
1911 break;
1912
1913 /* ===== Posts: delete ===== */
1914 case 'wp_delete_post':
1915 if ( empty( $a['ID'] ) ) {
1916 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
1917 break;
1918 }
1919 $delete_id = intval( $a['ID'] );
1920 $del = wp_delete_post( $delete_id, !empty( $a['force'] ) );
1921 if ( $del ) {
1922 $this->bust_post_cache( $delete_id, [ 'tool' => 'wp_delete_post' ] );
1923 $this->add_result_text( $r, 'Post #' . $a['ID'] . ' deleted' );
1924 }
1925 else {
1926 $r['error'] = [ 'code' => -32603, 'message' => 'Deletion failed' ];
1927 }
1928 break;
1929
1930 /* ===== Posts: alter (search/replace) ===== */
1931 case 'wp_alter_post':
1932 if ( empty( $a['ID'] ) || empty( $a['field'] ) || !isset( $a['search'] ) || !isset( $a['replace'] ) ) {
1933 $r['error'] = [ 'code' => -32602, 'message' => 'ID, field, search, and replace required' ];
1934 break;
1935 }
1936 $post_id = intval( $a['ID'] );
1937 $field = sanitize_key( $a['field'] );
1938 $search = $a['search'];
1939 $replace = $a['replace'];
1940 $is_regex = !empty( $a['regex'] );
1941 $flags = isset( $a['flags'] ) && is_string( $a['flags'] ) ? $a['flags'] : '';
1942
1943 // Validate field
1944 $allowed_fields = [ 'post_content', 'post_excerpt', 'post_title' ];
1945 if ( !in_array( $field, $allowed_fields, true ) ) {
1946 $r['error'] = [ 'code' => -32602, 'message' => 'Field must be: post_content, post_excerpt, or post_title' ];
1947 break;
1948 }
1949
1950 $post = get_post( $post_id );
1951 if ( !$post ) {
1952 $r['error'] = [ 'code' => -32602, 'message' => 'Post not found' ];
1953 break;
1954 }
1955
1956 $content = $post->$field;
1957 $count = 0;
1958
1959 if ( $is_regex ) {
1960 list( $compiled, $regex_err ) = $this->compile_alter_regex( $search, $flags );
1961 if ( $regex_err !== null ) {
1962 $r['error'] = [ 'code' => -32602, 'message' => $regex_err ];
1963 break;
1964 }
1965 $new_content = preg_replace( $compiled, $replace, $content, -1, $count );
1966 if ( $new_content === null ) {
1967 $msg = function_exists( 'preg_last_error_msg' ) ? preg_last_error_msg() : 'PCRE error code ' . preg_last_error();
1968 $r['error'] = [ 'code' => -32603, 'message' => 'Regex replacement failed: ' . $msg ];
1969 break;
1970 }
1971 }
1972 else {
1973 $new_content = str_replace( $search, $replace, $content, $count );
1974 }
1975
1976 if ( $count === 0 ) {
1977 $this->add_result_text( $r, 'No occurrences found; post unchanged.' );
1978 break;
1979 }
1980
1981 // wp_update_post() runs wp_unslash() internally, which would strip the
1982 // backslash from Unicode escapes like \u003c in block JSON (Rank Math
1983 // FAQ, etc.) and silently corrupt the post. Pre-slash to compensate.
1984 $update = wp_update_post( wp_slash( [ 'ID' => $post_id, $field => $new_content ] ), true );
1985 if ( is_wp_error( $update ) ) {
1986 $r['error'] = [ 'code' => $update->get_error_code(), 'message' => $update->get_error_message() ];
1987 break;
1988 }
1989
1990 $this->bust_post_cache( $post_id, [ 'tool' => 'wp_alter_post' ] );
1991 $this->add_result_text( $r, $count . ' replacement' . ( $count === 1 ? '' : 's' ) . ' applied to ' . $field . ' of post #' . $post_id );
1992 break;
1993
1994 /* ===== Post-meta ===== */
1995 case 'wp_get_post_meta':
1996 if ( empty( $a['ID'] ) ) {
1997 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
1998 break;
1999 }
2000 $pid = intval( $a['ID'] );
2001 $out = ( $a['key'] ?? '' ) ? get_post_meta( $pid, sanitize_key( $a['key'] ), true ) : get_post_meta( $pid );
2002 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
2003 break;
2004
2005 case 'wp_update_post_meta':
2006 if ( empty( $a['ID'] ) ) {
2007 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
2008 break;
2009 }
2010 $pid = intval( $a['ID'] );
2011
2012 // Handle JSON strings for meta (some MCP clients send objects as JSON strings)
2013 $meta = $a['meta'] ?? null;
2014 if ( is_string( $meta ) ) {
2015 $meta = json_decode( $meta, true );
2016 }
2017
2018 // Pass values as-is: update_post_meta() serializes arrays itself, so
2019 // maybe_serialize() here double-serialized nested arrays into a string.
2020 if ( !empty( $meta ) && is_array( $meta ) ) {
2021 foreach ( $meta as $k => $v ) {
2022 update_post_meta( $pid, sanitize_key( $k ), $v );
2023 }
2024 }
2025 elseif ( isset( $a['key'], $a['value'] ) ) {
2026 update_post_meta( $pid, sanitize_key( $a['key'] ), $a['value'] );
2027 }
2028 else {
2029 $r['error'] = [ 'code' => -32602, 'message' => 'meta array or key/value required' ];
2030 break;
2031 }
2032 $this->add_result_text( $r, 'Meta updated for post #' . $pid );
2033 break;
2034
2035 case 'wp_delete_post_meta':
2036 if ( empty( $a['ID'] ) || empty( $a['key'] ) ) {
2037 $r['error'] = [ 'code' => -32602, 'message' => 'ID & key required' ];
2038 break;
2039 }
2040 $pid = intval( $a['ID'] );
2041 $key = sanitize_key( $a['key'] );
2042 // delete_post_meta() serializes the match value itself; don't pre-serialize.
2043 $done = isset( $a['value'] ) ? delete_post_meta( $pid, $key, $a['value'] ) : delete_post_meta( $pid, $key );
2044 if ( $done ) {
2045 $this->add_result_text( $r, 'Meta deleted on post #' . $pid );
2046 }
2047 else {
2048 $r['error'] = [ 'code' => -32603, 'message' => 'Deletion failed' ];
2049 }
2050 break;
2051
2052 /* ===== Featured image ===== */
2053 case 'wp_set_featured_image':
2054 if ( empty( $a['post_id'] ) ) {
2055 $r['error'] = [ 'code' => -32602, 'message' => 'post_id required' ];
2056 break;
2057 }
2058 $post_id = intval( $a['post_id'] );
2059 $media_id = isset( $a['media_id'] ) ? intval( $a['media_id'] ) : 0;
2060 if ( $media_id ) {
2061 $done = set_post_thumbnail( $post_id, $media_id );
2062 if ( $done ) {
2063 $this->add_result_text( $r, 'Featured image set on post #' . $post_id );
2064 }
2065 else {
2066 $r['error'] = [ 'code' => -32603, 'message' => 'Failed to set thumbnail' ];
2067 }
2068 }
2069 else {
2070 delete_post_thumbnail( $post_id );
2071 $this->add_result_text( $r, 'Featured image removed from post #' . $post_id );
2072 }
2073 break;
2074
2075 /* ===== Taxonomies ===== */
2076 case 'wp_get_taxonomies':
2077 $pt = sanitize_key( $a['post_type'] ?? 'post' );
2078 $out = [];
2079 foreach ( get_object_taxonomies( $pt, 'objects' ) as $t ) {
2080 $out[] = [ 'key' => $t->name, 'label' => $t->label ];
2081 }
2082 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
2083 break;
2084
2085 case 'wp_get_terms':
2086 $tax = sanitize_key( $a['taxonomy'] );
2087 $args = [
2088 'taxonomy' => $tax,
2089 'hide_empty' => false,
2090 'number' => intval( $a['limit'] ?? 0 ),
2091 'search' => $a['search'] ?? '',
2092 ];
2093 if ( isset( $a['parent'] ) ) {
2094 $args['parent'] = intval( $a['parent'] );
2095 }
2096 $out = [];
2097 foreach ( get_terms( $args ) as $t ) {
2098 $out[] = [ 'term_id' => $t->term_id, 'name' => $t->name, 'slug' => $t->slug, 'count' => $t->count ];
2099 }
2100 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
2101 break;
2102
2103 case 'wp_create_term':
2104 if ( empty( $a['term_name'] ) ) {
2105 $r['error'] = [ 'code' => -32602, 'message' => 'term_name required' ];
2106 break;
2107 }
2108 $tax = sanitize_key( $a['taxonomy'] );
2109 $args = [];
2110 if ( $a['slug'] ?? '' ) {
2111 $args['slug'] = sanitize_title( $a['slug'] );
2112 }
2113 if ( $a['description'] ?? '' ) {
2114 $args['description'] = sanitize_text_field( $a['description'] );
2115 }
2116 if ( isset( $a['parent'] ) ) {
2117 $args['parent'] = intval( $a['parent'] );
2118 }
2119 $term = wp_insert_term( sanitize_text_field( $a['term_name'] ), $tax, $args );
2120 if ( is_wp_error( $term ) ) {
2121 $r['error'] = [ 'code' => $term->get_error_code(), 'message' => $term->get_error_message() ];
2122 }
2123 else {
2124 $this->add_result_text( $r, 'Term ' . $term['term_id'] . ' created' );
2125 }
2126 break;
2127
2128 case 'wp_update_term':
2129 $tid = intval( $a['term_id'] ?? 0 );
2130 if ( !$tid ) {
2131 $r['error'] = [ 'code' => -32602, 'message' => 'term_id required' ];
2132 break;
2133 }
2134 $tax = sanitize_key( $a['taxonomy'] );
2135 $uargs = [];
2136 foreach ( [ 'name', 'slug', 'description', 'parent' ] as $f ) {
2137 if ( isset( $a[$f] ) ) {
2138 $uargs[$f] = $a[$f];
2139 }
2140 }
2141 $t = wp_update_term( $tid, $tax, $uargs );
2142 if ( is_wp_error( $t ) ) {
2143 $r['error'] = [ 'code' => $t->get_error_code(), 'message' => $t->get_error_message() ];
2144 }
2145 else {
2146 $this->add_result_text( $r, 'Term ' . $tid . ' updated' );
2147 }
2148 break;
2149
2150 case 'wp_delete_term':
2151 $tid = intval( $a['term_id'] ?? 0 );
2152 if ( !$tid ) {
2153 $r['error'] = [ 'code' => -32602, 'message' => 'term_id required' ];
2154 break;
2155 }
2156 $tax = sanitize_key( $a['taxonomy'] );
2157 $d = wp_delete_term( $tid, $tax );
2158 if ( $d ) {
2159 $this->add_result_text( $r, 'Term ' . $tid . ' deleted' );
2160 }
2161 else {
2162 $r['error'] = [ 'code' => -32603, 'message' => 'Deletion failed' ];
2163 }
2164 break;
2165
2166 case 'wp_get_post_terms':
2167 if ( empty( $a['ID'] ) ) {
2168 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
2169 break;
2170 }
2171 $tax = sanitize_key( $a['taxonomy'] ?? 'category' );
2172 $out = [];
2173 foreach ( wp_get_post_terms( intval( $a['ID'] ), $tax, [ 'fields' => 'all' ] ) as $t ) {
2174 $out[] = [ 'term_id' => $t->term_id, 'name' => $t->name ];
2175 }
2176 $this->add_result_text( $r, wp_json_encode( $out, JSON_PRETTY_PRINT ) );
2177 break;
2178
2179 case 'wp_add_post_terms':
2180 if ( empty( $a['ID'] ) || empty( $a['terms'] ) ) {
2181 $r['error'] = [ 'code' => -32602, 'message' => 'ID & terms required' ];
2182 break;
2183 }
2184 $terms = $a['terms'];
2185 // Handle JSON strings (some MCP clients send arrays as JSON strings)
2186 if ( is_string( $terms ) ) {
2187 $terms = json_decode( $terms, true ) ?? [];
2188 }
2189 $tax = sanitize_key( $a['taxonomy'] ?? 'category' );
2190 $append = !isset( $a['append'] ) || $a['append'];
2191 $set = wp_set_post_terms( intval( $a['ID'] ), $terms, $tax, $append );
2192 if ( is_wp_error( $set ) ) {
2193 $r['error'] = [ 'code' => $set->get_error_code(), 'message' => $set->get_error_message() ];
2194 }
2195 else {
2196 $this->add_result_text( $r, 'Terms set for post #' . $a['ID'] );
2197 }
2198 break;
2199
2200 /* ===== Media: list ===== */
2201 case 'wp_get_media':
2202 $q = [
2203 'post_type' => 'attachment',
2204 's' => $a['search'] ?? '',
2205 'posts_per_page' => max( 1, intval( $a['limit'] ?? 10 ) ),
2206 'post_status' => 'inherit',
2207 ];
2208 if ( isset( $a['author'] ) ) {
2209 $q['author'] = intval( $a['author'] );
2210 }
2211 elseif ( $a['author_name'] ?? '' ) {
2212 $q['author_name'] = sanitize_title( $a['author_name'] );
2213 }
2214 $d = [];
2215 if ( $a['after'] ?? '' ) {
2216 $d['after'] = $a['after'];
2217 }
2218 if ( $a['before'] ?? '' ) {
2219 $d['before'] = $a['before'];
2220 }
2221 if ( $d ) {
2222 $q['date_query'] = [ $d ];
2223 }
2224 $list = [];
2225 foreach ( get_posts( $q ) as $m ) {
2226 $list[] = [ 'ID' => $m->ID, 'title' => $m->post_title, 'url' => wp_get_attachment_url( $m->ID ) ];
2227 }
2228 $this->add_result_text( $r, wp_json_encode( $list, JSON_PRETTY_PRINT ) );
2229 break;
2230
2231 /* ===== Media: upload ===== */
2232 case 'wp_upload_media':
2233 $has_url = !empty( $a['url'] );
2234 $has_base64 = !empty( $a['base64'] ) && !empty( $a['filename'] );
2235 if ( !$has_url && !$has_base64 ) {
2236 $r['error'] = [ 'code' => -32602, 'message' => 'Provide either url, or base64 + filename.' ];
2237 break;
2238 }
2239 try {
2240 require_once ABSPATH . 'wp-admin/includes/file.php';
2241 require_once ABSPATH . 'wp-admin/includes/media.php';
2242 require_once ABSPATH . 'wp-admin/includes/image.php';
2243
2244 if ( $has_url ) {
2245 $tmp = download_url( $a['url'] );
2246 if ( is_wp_error( $tmp ) ) {
2247 // WP_Error codes are strings (e.g. http_request_failed); Exception's
2248 // $code must be an int, so keep the code in the message instead.
2249 throw new Exception( 'Download failed (' . $tmp->get_error_code() . '): ' . $tmp->get_error_message() );
2250 }
2251 // URLs like https://picsum.photos/800/600 have no file extension, so
2252 // basename() yields a name that media_handle_sideload() rejects. Sniff
2253 // the real type of the downloaded file and append a proper extension.
2254 $name = basename( parse_url( $a['url'], PHP_URL_PATH ) );
2255 if ( $name === '' || pathinfo( $name, PATHINFO_EXTENSION ) === '' ) {
2256 $ext = '';
2257 $check = wp_check_filetype_and_ext( $tmp, $name ?: 'image' );
2258 if ( !empty( $check['ext'] ) ) {
2259 $ext = $check['ext'];
2260 }
2261 elseif ( function_exists( 'mime_content_type' ) ) {
2262 $map = [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp' ];
2263 $ext = $map[ mime_content_type( $tmp ) ] ?? '';
2264 }
2265 $name = ( $name ?: 'image' ) . ( $ext ? '.' . $ext : '' );
2266 }
2267 $file = [ 'name' => sanitize_file_name( $name ), 'tmp_name' => $tmp ];
2268 }
2269 else {
2270 $decoded = base64_decode( $a['base64'], true );
2271 if ( $decoded === false ) {
2272 throw new Exception( 'Invalid base64 data.' );
2273 }
2274 $tmp = wp_tempnam( $a['filename'] );
2275 file_put_contents( $tmp, $decoded );
2276 $file = [ 'name' => sanitize_file_name( $a['filename'] ), 'tmp_name' => $tmp ];
2277 }
2278
2279 $id = media_handle_sideload( $file, 0, $a['description'] ?? '' );
2280 @unlink( $tmp );
2281 if ( is_wp_error( $id ) ) {
2282 throw new Exception( 'Sideload failed (' . $id->get_error_code() . '): ' . $id->get_error_message() );
2283 }
2284 if ( $a['title'] ?? '' ) {
2285 wp_update_post( wp_slash( [ 'ID' => $id, 'post_title' => sanitize_text_field( $a['title'] ) ] ) );
2286 }
2287 if ( $a['alt'] ?? '' ) {
2288 update_post_meta( $id, '_wp_attachment_image_alt', sanitize_text_field( $a['alt'] ) );
2289 }
2290 $this->add_result_text( $r, wp_get_attachment_url( $id ) );
2291 }
2292 catch ( \Throwable $e ) {
2293 $r['error'] = [ 'code' => $e->getCode() ?: -32603, 'message' => $e->getMessage() ];
2294 }
2295 break;
2296
2297 /* ===== Media: upload alternative (two-step) ===== */
2298 case 'wp_upload_request':
2299 if ( empty( $a['filename'] ) ) {
2300 $r['error'] = [ 'code' => -32602, 'message' => 'filename required' ];
2301 break;
2302 }
2303 try {
2304 $token = wp_generate_password( 32, false );
2305 $transient_key = 'mwai_mcp_upload_' . $token;
2306 $data = [
2307 'filename' => sanitize_file_name( $a['filename'] ),
2308 'title' => $a['title'] ?? '',
2309 'description' => $a['description'] ?? '',
2310 'alt' => $a['alt'] ?? '',
2311 ];
2312 set_transient( $transient_key, $data, 5 * MINUTE_IN_SECONDS );
2313 $upload_url = rest_url( 'mcp/v1/upload/' . $token );
2314 $this->add_result_text( $r, wp_json_encode( [
2315 'upload_url' => $upload_url,
2316 'expires_in' => '5 minutes',
2317 'usage' => 'curl -X POST -F "file=@/path/to/' . $a['filename'] . '" "' . $upload_url . '"',
2318 ], JSON_PRETTY_PRINT ) );
2319 }
2320 catch ( \Throwable $e ) {
2321 $r['error'] = [ 'code' => $e->getCode() ?: -32603, 'message' => $e->getMessage() ];
2322 }
2323 break;
2324
2325 /* ===== Media: update ===== */
2326 case 'wp_update_media':
2327 if ( empty( $a['ID'] ) ) {
2328 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
2329 break;
2330 }
2331 $upd = [ 'ID' => intval( $a['ID'] ) ];
2332 if ( $a['title'] ?? '' ) {
2333 $upd['post_title'] = sanitize_text_field( $a['title'] );
2334 }
2335 if ( $a['caption'] ?? '' ) {
2336 $upd['post_excerpt'] = $this->clean_html( $a['caption'] );
2337 }
2338 if ( $a['description'] ?? '' ) {
2339 $upd['post_content'] = $this->clean_html( $a['description'] );
2340 }
2341 $u = wp_update_post( wp_slash( $upd ), true );
2342 if ( is_wp_error( $u ) ) {
2343 $r['error'] = [ 'code' => $u->get_error_code(), 'message' => $u->get_error_message() ];
2344 }
2345 else {
2346 if ( $a['alt'] ?? '' ) {
2347 update_post_meta( $u, '_wp_attachment_image_alt', sanitize_text_field( $a['alt'] ) );
2348 }
2349 $this->add_result_text( $r, 'Media #' . $u . ' updated' );
2350 }
2351 break;
2352
2353 /* ===== Media: delete ===== */
2354 case 'wp_delete_media':
2355 if ( empty( $a['ID'] ) ) {
2356 $r['error'] = [ 'code' => -32602, 'message' => 'ID required' ];
2357 break;
2358 }
2359 $d = wp_delete_post( intval( $a['ID'] ), !empty( $a['force'] ) );
2360 if ( $d ) {
2361 $this->add_result_text( $r, 'Media #' . $a['ID'] . ' deleted' );
2362 }
2363 else {
2364 $r['error'] = [ 'code' => -32603, 'message' => 'Deletion failed' ];
2365 }
2366 break;
2367
2368 /* ===== MWAI Vision ===== */
2369 case 'mwai_vision':
2370 if ( empty( $a['message'] ) ) {
2371 $r['error'] = [ 'code' => -32602, 'message' => 'message required' ];
2372 break;
2373 }
2374 global $mwai;
2375 if ( !isset( $mwai ) ) {
2376 $r['error'] = [ 'code' => -32603, 'message' => 'MWAI not found' ];
2377 break;
2378 }
2379 $analysis = $mwai->simpleVisionQuery(
2380 $a['message'],
2381 $a['url'] ?? null,
2382 $a['path'] ?? null,
2383 [ 'scope' => 'mcp' ]
2384 );
2385 $this->add_result_text( $r, is_string( $analysis ) ? $analysis : wp_json_encode( $analysis, JSON_PRETTY_PRINT ) );
2386 break;
2387
2388 /* ===== MWAI Image ===== */
2389 case 'mwai_image':
2390 if ( empty( $a['message'] ) ) {
2391 $r['error'] = [ 'code' => -32602, 'message' => 'message required' ];
2392 break;
2393 }
2394 global $mwai;
2395 if ( !isset( $mwai ) ) {
2396 $r['error'] = [ 'code' => -32603, 'message' => 'MWAI not found' ];
2397 break;
2398 }
2399
2400 $media = $mwai->imageQueryForMediaLibrary( $a['message'], [ 'scope' => 'mcp' ], $a['postId'] ?? null );
2401 if ( is_wp_error( $media ) ) {
2402 $r['error'] = [ 'code' => $media->get_error_code(), 'message' => $media->get_error_message() ];
2403 break;
2404 }
2405
2406 $mid = intval( $media['id'] );
2407
2408 $upd = [ 'ID' => $mid ];
2409 if ( !empty( $a['title'] ) ) {
2410 $upd['post_title'] = sanitize_text_field( $a['title'] );
2411 }
2412 if ( !empty( $a['caption'] ) ) {
2413 $upd['post_excerpt'] = $this->clean_html( $a['caption'] );
2414 }
2415 if ( !empty( $a['description'] ) ) {
2416 $upd['post_content'] = $this->clean_html( $a['description'] );
2417 }
2418 if ( count( $upd ) > 1 ) {
2419 wp_update_post( wp_slash( $upd ), true );
2420 }
2421 if ( array_key_exists( 'alt', $a ) ) {
2422 update_post_meta( $mid, '_wp_attachment_image_alt', sanitize_text_field( (string) $a['alt'] ) );
2423 }
2424
2425 $media = [
2426 'id' => $mid,
2427 'url' => wp_get_attachment_url( $mid ),
2428 'title' => get_the_title( $mid ),
2429 'caption' => wp_get_attachment_caption( $mid ),
2430 'alt' => get_post_meta( $mid, '_wp_attachment_image_alt', true ),
2431 ];
2432 $this->add_result_text( $r, wp_json_encode( $media, JSON_PRETTY_PRINT ) );
2433 break;
2434
2435 default: $r['error'] = [ 'code' => -32601, 'message' => 'Unknown tool' ];
2436 }
2437
2438 // Generic post-write hook: fires after any successful content-mutating tool
2439 // (create/update/delete of posts, terms, meta, media, comments, users,
2440 // options...). Integrations can hook this to purge page/object caches, reindex
2441 // search, write an audit log, etc. The options/object cache is already updated
2442 // by WordPress, but full-page caches (Varnish, WP Rocket, Cloudflare) are not,
2443 // so a cache layer should listen here. Reads never trigger it.
2444 if ( empty( $r['error'] ) && $this->is_mutating_tool( $tool ) ) {
2445 do_action( 'mwai_mcp_mutate', $tool, $a, $r );
2446 }
2447 return $r;
2448 }
2449
2450 // Whether a tool changes site state (so the mwai_mcp_mutate hook should fire).
2451 // Anything declared accessLevel "write" mutates; a few "admin" tools mutate too
2452 // (the rest, e.g. wp_get_option, are reads).
2453 private function is_mutating_tool( string $tool ): bool {
2454 $defs = $this->tools();
2455 if ( ( $defs[ $tool ]['accessLevel'] ?? '' ) === 'write' ) {
2456 return true;
2457 }
2458 return in_array( $tool, [ 'wp_update_option', 'wp_create_user', 'wp_update_user' ], true );
2459 }
2460 #endregion
2461 }
2462