405. JSON-RPC batches -> -32600. * - Lifecycle: initialize, notifications/* (202), ping, tools/list, tools/call. */ class Mcp_Server { const PROTOCOL_VERSION = '2025-06-18'; const ACCEPTED_PROTOCOLS = array( '2025-03-26', '2025-06-18' ); /** * Tool registry. * * @var Mcp_Tools */ private $tools; /** * Telemetry counters. * * @var Telemetry */ private $telemetry; /** * Constructor. * * @param Mcp_Tools $tools Tool registry. * @param Telemetry $telemetry Telemetry counters. */ public function __construct( $tools, $telemetry ) { $this->tools = $tools; $this->telemetry = $telemetry; } /** * Hook the REST route. */ public function register_hooks() { add_action( 'rest_api_init', array( $this, 'register_routes' ) ); } /** * Register the /42a/v1/mcp route. */ public function register_routes() { register_rest_route( '42a/v1', '/mcp', array( array( 'methods' => array( 'POST', 'GET' ), 'callback' => array( $this, 'handle' ), 'permission_callback' => '__return_true', ), ) ); } /** * Handle an MCP HTTP request. Sends the response itself and exits so we * fully control status codes, Content-Type and empty 202 bodies. * * @param \WP_REST_Request $request REST request. */ public function handle( $request ) { if ( 'GET' === $request->get_method() ) { $this->respond( 405, $this->jsonrpc_error( null, -32600, 'This MCP server is stateless: use POST with a JSON-RPC 2.0 message. GET/SSE is not supported.' ), array( 'Allow' => 'POST' ) ); } $this->telemetry->increment( 'mcp_requests' ); $this->telemetry->record_bot_hit_from_request(); $raw = $request->get_body(); $message = json_decode( $raw, true ); if ( null === $message && JSON_ERROR_NONE !== json_last_error() ) { $this->respond( 400, $this->jsonrpc_error( null, -32700, 'Parse error: invalid JSON.' ) ); } if ( $this->is_list( $message ) ) { $this->respond( 400, $this->jsonrpc_error( null, -32600, 'Batching not supported.' ) ); } if ( ! is_array( $message ) || ! isset( $message['method'] ) || ! is_string( $message['method'] ) ) { $this->respond( 400, $this->jsonrpc_error( null, -32600, 'Invalid Request: expected a single JSON-RPC 2.0 message with a method.' ) ); } $method = $message['method']; $id = array_key_exists( 'id', $message ) ? $message['id'] : null; $params = isset( $message['params'] ) && is_array( $message['params'] ) ? $message['params'] : array(); // Notifications (no response expected) -> 202 Accepted, empty body. if ( 0 === strpos( $method, 'notifications/' ) ) { $this->respond( 202, null ); } switch ( $method ) { case 'initialize': $this->respond( 200, $this->jsonrpc_result( $id, $this->initialize_result( $params ) ) ); break; // Unreachable - respond() exits. case 'ping': $this->respond( 200, $this->jsonrpc_result( $id, new \stdClass() ) ); break; case 'tools/list': $this->respond( 200, $this->jsonrpc_result( $id, array( 'tools' => $this->tools->describe_tools() ) ) ); break; case 'tools/call': $this->handle_tool_call( $id, $params ); break; default: $this->respond( 200, $this->jsonrpc_error( $id, -32601, sprintf( 'Method not found: %s', $method ) ) ); } } /** * initialize result payload. * * @param array $params Client params. * @return array */ private function initialize_result( $params ) { $client_protocol = isset( $params['protocolVersion'] ) ? (string) $params['protocolVersion'] : ''; $protocol = in_array( $client_protocol, self::ACCEPTED_PROTOCOLS, true ) ? $client_protocol : self::PROTOCOL_VERSION; $site_name = get_bloginfo( 'name' ); $tagline = get_bloginfo( 'description' ); $instructions = sprintf( 'Read-only MCP server for the WordPress site "%s"%s. Tools: get_site_info for an overview, search_content to find pages/posts, get_page to fetch any page as markdown, list_recent_posts and list_categories to browse%s. All content is public; no authentication is required.', $site_name, '' !== trim( (string) $tagline ) ? ' (' . trim( (string) $tagline ) . ')' : '', class_exists( 'WooCommerce' ) ? ', search_products and get_product for the product catalog' : '' ); return array( 'protocolVersion' => $protocol, 'capabilities' => array( 'tools' => new \stdClass(), ), 'serverInfo' => array( 'name' => 'ael-42a', 'title' => $site_name, 'version' => AEL42A_VERSION, ), 'instructions' => $instructions, ); } /** * tools/call dispatcher. * * @param mixed $id JSON-RPC id. * @param array $params Call params. */ private function handle_tool_call( $id, $params ) { $name = isset( $params['name'] ) ? (string) $params['name'] : ''; $arguments = isset( $params['arguments'] ) && is_array( $params['arguments'] ) ? $params['arguments'] : array(); $tool = $this->tools->get_tool( $name ); if ( null === $tool ) { $this->respond( 200, $this->jsonrpc_error( $id, -32602, sprintf( 'Unknown tool: %s', $name ) ) ); } if ( ! empty( $tool['requires_auth'] ) ) { // v1 has no authenticated transport - always reject. $this->respond( 200, $this->jsonrpc_error( $id, -32001, sprintf( 'Tool "%s" requires authentication, which this server does not offer yet.', $name ) ) ); } $this->telemetry->increment_tool( $name ); try { $result = call_user_func( $tool['callback'], $arguments ); } catch ( \Throwable $e ) { $result = new \WP_Error( 'tool_exception', $e->getMessage() ); } if ( is_wp_error( $result ) ) { $this->respond( 200, $this->jsonrpc_result( $id, array( 'content' => array( array( 'type' => 'text', 'text' => $result->get_error_message(), ), ), 'isError' => true, ) ) ); } if ( is_string( $result ) ) { $text = $result; } else { $text = wp_json_encode( $result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); } $this->respond( 200, $this->jsonrpc_result( $id, array( 'content' => array( array( 'type' => 'text', 'text' => (string) $text, ), ), 'isError' => false, ) ) ); } /** * Build a JSON-RPC result envelope. * * @param mixed $id Request id. * @param mixed $result Result payload. * @return array */ private function jsonrpc_result( $id, $result ) { return array( 'jsonrpc' => '2.0', 'id' => $id, 'result' => $result, ); } /** * Build a JSON-RPC error envelope. * * @param mixed $id Request id (null when unknown). * @param int $code JSON-RPC error code. * @param string $message Error message. * @return array */ private function jsonrpc_error( $id, $code, $message ) { return array( 'jsonrpc' => '2.0', 'id' => $id, 'error' => array( 'code' => $code, 'message' => $message, ), ); } /** * Send the HTTP response and terminate. * * @param int $status HTTP status. * @param array|null $payload JSON payload (null -> empty body). * @param array $headers Extra headers. */ private function respond( $status, $payload, $headers = array() ) { status_header( $status ); header( 'Content-Type: application/json; charset=utf-8' ); foreach ( $headers as $name => $value ) { header( $name . ': ' . $value ); } if ( null !== $payload ) { echo wp_json_encode( $payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); // phpcs:ignore WordPress.Security.EscapeOutput -- JSON body. } exit; } /** * PHP 7.4-compatible array_is_list (detects JSON-RPC batches). * * @param mixed $value Decoded JSON value. * @return bool */ private function is_list( $value ) { if ( ! is_array( $value ) || array() === $value ) { // `{}` and `[]` both decode to array(); treat both as a (bad) // single message rather than a batch. return false; } return array_keys( $value ) === range( 0, count( $value ) - 1 ); } }