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