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="b"), 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 |