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