logger = $logger; $this->cpt = $cpt; $this->executor = $executor; } /** * Register the MCP REST route. * * @since 1.0.0 * * @return void */ public function register_routes(): void { register_rest_route( 'wlconduit-mcp', '/v1', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'handle_request' ), 'permission_callback' => array( $this, 'check_auth' ), ), array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'handle_get' ), 'permission_callback' => array( $this, 'check_auth' ), ), ) ); } /** * Reject GET requests with 405 — SSE streams not supported. * * Per MCP spec, servers that do not offer an SSE stream must * return 405 Method Not Allowed on GET. * * @since 1.0.0 * * @return WP_REST_Response */ public function handle_get(): WP_REST_Response { return new WP_REST_Response( null, 405 ); } /** * Permission callback for the MCP endpoint. * * Performs the first stage of access control by rejecting unauthenticated * requests with a 401 response. This method verifies the resulting login * state and applies rate limiting. * * Authorization for individual tools is enforced separately at the protocol * layer: * - tools/list filters returned tools by user role via * user_has_required_roles(). * - tools/call rejects calls to tools whose required_roles do not intersect * with the current user's roles. * * This layered approach lets the endpoint serve any authenticated user * while ensuring each tool only exposes data to users explicitly granted * access by the site administrator. Sites that need stricter access can * use the wlconduit_mcp_authorize_request filter below to add custom * checks (e.g. require a specific capability or IP allowlist). * * @since 1.0.0 * * @param WP_REST_Request $request The incoming request. * @return bool|WP_Error True if authenticated and within rate limits, WP_Error otherwise. */ public function check_auth( WP_REST_Request $request ): bool|WP_Error { if ( ! current_user_can( 'read' ) ) { return false; } $rate_check = $this->check_rate_limit(); if ( is_wp_error( $rate_check ) ) { return $rate_check; } /** * Filter to allow sites to add custom authorization checks on MCP requests. * * Return true to allow, or a WP_Error to deny. * * @since 1.0.0 * * @param bool|WP_Error $authorized Whether the request is authorized. * @param WP_User $user The authenticated user. * @param WP_REST_Request $request The incoming request. */ return apply_filters( 'wlconduit_mcp_authorize_request', true, wp_get_current_user(), $request ); } /** * Check if rate limiting is enabled. * * @since 1.0.0 * * @return bool */ private function is_rate_limiting_enabled(): bool { $enabled = get_option( 'wlconduit_rate_limiting_enabled', '1' ) === '1'; /** * Filter to override the rate limiting setting programmatically. * * Takes priority over the admin setting. * * @since 1.0.0 * * @param bool $enabled Whether rate limiting is enabled. */ return (bool) apply_filters( 'wlconduit_mcp_rate_limiting_enabled', $enabled ); } /** * Check and enforce rate limiting for the current user. * * Uses transients to track request count per user per minute. * If the limit is exceeded, the user is hard-blocked for a configurable duration. * * @since 1.0.0 * * @return bool|WP_Error True if allowed, WP_Error if rate limited. */ private function check_rate_limit(): bool|WP_Error { // Memoize: WordPress can invoke permission_callback multiple times per // request, but we want to count exactly one increment per real request. if ( $this->rate_limit_checked ) { return $this->rate_limit_result; } $this->rate_limit_checked = true; if ( ! $this->is_rate_limiting_enabled() ) { $this->rate_limit_result = true; return $this->rate_limit_result; } $user_id = get_current_user_id(); $block_key = 'wlconduit_block_' . $user_id; $rate_key = 'wlconduit_rate_' . $user_id; $max_requests = absint( get_option( 'wlconduit_rate_limit', 20 ) ); $block_duration = absint( get_option( 'wlconduit_rate_block_duration', 60 ) ); // Check if user is currently blocked. if ( get_transient( $block_key ) ) { $this->rate_limit_result = new WP_Error( 'rate_limited', __( 'Rate limit exceeded. You are temporarily blocked.', 'ai-conduit' ), array( 'status' => 429 ) ); return $this->rate_limit_result; } // Get current request count. $count = (int) get_transient( $rate_key ); if ( $count >= $max_requests ) { // Block the user. set_transient( $block_key, 1, $block_duration * MINUTE_IN_SECONDS ); delete_transient( $rate_key ); $this->rate_limit_result = new WP_Error( 'rate_limited', sprintf( /* translators: %d: block duration in minutes */ __( 'Rate limit exceeded. Blocked for %d minutes.', 'ai-conduit' ), $block_duration ), array( 'status' => 429 ) ); return $this->rate_limit_result; } // Increment counter. Always preserve the 60-second TTL. set_transient( $rate_key, $count + 1, MINUTE_IN_SECONDS ); $this->rate_limit_result = true; return $this->rate_limit_result; } /** * Handle an incoming MCP JSON-RPC request. * * @since 1.0.0 * * @param WP_REST_Request $request The incoming request. * @return WP_REST_Response */ public function handle_request( WP_REST_Request $request ): WP_REST_Response { $body = $request->get_json_params(); $body = is_array( $body ) ? $body : array(); $method = $body['method'] ?? ''; $params = $body['params'] ?? array(); $tool_name = $params['name'] ?? ''; $accept = $request->get_header( 'accept' ); if ( ! $accept || false === strpos( $accept, 'application/json' ) ) { $error = $this->json_rpc_error( null, -32600, __( 'Accept header must include application/json.', 'ai-conduit' ) ); $this->log_request( $method, $tool_name, $body, $error->get_data(), 'error' ); return $error; } if ( empty( $body ) ) { $error = $this->json_rpc_error( null, -32700, __( 'Parse error: empty or invalid JSON.', 'ai-conduit' ) ); $this->log_request( $method, $tool_name, $body, $error->get_data(), 'error' ); return $error; } if ( ! isset( $body['jsonrpc'] ) || '2.0' !== $body['jsonrpc'] ) { $error = $this->json_rpc_error( null, -32600, __( 'Invalid JSON-RPC version. Must be "2.0".', 'ai-conduit' ) ); $this->log_request( $method, $tool_name, $body, $error->get_data(), 'error' ); return $error; } if ( empty( $method ) ) { $error = $this->json_rpc_error( $body['id'] ?? null, -32600, __( 'Missing method field.', 'ai-conduit' ) ); $this->log_request( $method, $tool_name, $body, $error->get_data(), 'error' ); return $error; } $id = $body['id'] ?? null; // Notifications have no id — return 202 Accepted. if ( null === $id ) { $this->log_request( $method, $tool_name, $body, array(), 'success' ); return new WP_REST_Response( null, 202 ); } $response = match ( $method ) { 'initialize' => $this->handle_initialize( $id, $params ), 'ping' => $this->handle_ping( $id ), 'tools/list' => $this->handle_tools_list( $id ), 'tools/call' => $this->handle_tools_call( $id, $params ), default => $this->json_rpc_error( $id, -32601, sprintf( /* translators: %s: method name */ __( 'Method not found: %s', 'ai-conduit' ), $method ) ), }; $response_data = $response->get_data(); $status = isset( $response_data['error'] ) ? 'error' : 'success'; $this->log_request( $method, $tool_name, $body, $response_data, $status ); return $response; } /** * Handle the MCP initialize method. * * @since 1.0.0 * * @param int|string $id The JSON-RPC request ID. * @param array $params The client-provided initialization params. * @return WP_REST_Response */ private function handle_initialize( int|string $id, array $params ): WP_REST_Response { $client_version = $params['protocolVersion'] ?? ''; // Per MCP spec: if we support the client's version, respond with it. // Otherwise, respond with our latest supported version. $negotiated = in_array( $client_version, self::SUPPORTED_VERSIONS, true ) ? $client_version : self::PROTOCOL_VERSION; return $this->json_rpc_result( $id, array( 'protocolVersion' => $negotiated, 'capabilities' => array( 'tools' => new \stdClass(), ), 'serverInfo' => array( 'name' => 'AI Conduit', 'version' => WLCONDUIT_VERSION, ), ) ); } /** * Handle the MCP ping method. * * @since 1.0.0 * * @param int|string $id The JSON-RPC request ID. * @return WP_REST_Response */ private function handle_ping( int|string $id ): WP_REST_Response { return $this->json_rpc_result( $id, new \stdClass() ); } /** * Handle the MCP tools/list method. * * Loads all published tools and generates MCP tool schemas * with inputSchema derived from each block's enabled filters. * * @since 1.0.0 * * @param int|string $id The JSON-RPC request ID. * @return WP_REST_Response */ private function handle_tools_list( int|string $id ): WP_REST_Response { $tools = $this->cpt->get_all_tools(); $tool_schemas = array(); foreach ( $tools as $tool ) { $full_tool = $this->cpt->get_tool( $tool['id'] ); if ( ! $full_tool ) { continue; } $config = $full_tool['config']; // Skip tools the user doesn't have the required role for. if ( ! $this->user_has_required_roles( $config['required_roles'] ?? array( 'administrator' ) ) ) { continue; } $properties = array(); $result_keys = array(); $seen_keys = array(); foreach ( $config['blocks'] ?? array() as $index => $block_entry ) { $block = Wlconduit_Block_Registry::get( $block_entry['block_type'] ?? '' ); if ( ! $block ) { continue; } $block_config = $block_entry['config'] ?? array(); $block_type = $block_entry['block_type'] ?? ''; // Only Posts blocks key by post_type; other block types ignore any // phantom post_type that legacy UI-saved tools may carry. $result_key = ( 'posts' === $block_type && ! empty( $block_config['post_type'] ) ) ? $block_config['post_type'] : $block_type; if ( isset( $seen_keys[ $result_key ] ) ) { $result_key = $result_key . '_' . $index; } $seen_keys[ $result_key ] = true; $result_keys[] = $result_key; // When joins are active, strip meta/taxonomy filters from schema — the query builder doesn't support them. $schema_config = $block_config; if ( ! empty( $block_config['joins'] ) ) { unset( $schema_config['meta_keys'], $schema_config['taxonomies'] ); } $block_props = $block->get_input_schema_properties( $schema_config ); foreach ( $block_props as $key => $prop ) { $properties[ $result_key . '_' . $key ] = $prop; } } // For multi-block tools, add _blocks enum so the LLM can select which blocks to execute. if ( count( $result_keys ) > 1 ) { $properties['_blocks'] = array( 'type' => 'array', 'description' => __( 'Optional. List which blocks to execute by result key. Omit to run all blocks.', 'ai-conduit' ), 'items' => array( 'type' => 'string', 'enum' => $result_keys, ), ); } $tool_schemas[] = array( 'name' => $config['tool_name'] ?? '', 'description' => $config['description'] ?? '', 'inputSchema' => array( 'type' => 'object', 'properties' => ! empty( $properties ) ? $properties : new \stdClass(), ), ); } return $this->json_rpc_result( $id, array( 'tools' => $tool_schemas ) ); } /** * Handle the MCP tools/call method. * * Finds the tool by name, validates arguments, and delegates * execution to the tool executor. * * @since 1.0.0 * * @param int|string $id The JSON-RPC request ID. * @param array $params The tools/call params containing name and arguments. * @return WP_REST_Response */ private function handle_tools_call( int|string $id, array $params ): WP_REST_Response { $tool_name = $params['name'] ?? ''; $arguments = $params['arguments'] ?? array(); if ( empty( $tool_name ) ) { return $this->json_rpc_error( $id, -32602, __( 'Missing tool name.', 'ai-conduit' ) ); } // Find the tool by name. $tool_config = $this->find_tool_by_name( $tool_name ); if ( ! $tool_config ) { return $this->json_rpc_error( $id, -32602, __( 'Tool not found or access denied.', 'ai-conduit' ) ); } if ( ! $this->user_has_required_roles( $tool_config['required_roles'] ?? array( 'administrator' ) ) ) { return $this->json_rpc_error( $id, -32602, __( 'Tool not found or access denied.', 'ai-conduit' ) ); } $result = $this->executor->execute( $tool_config, $arguments ); return $this->json_rpc_result( $id, $result ); } /** * Find a tool configuration by its tool_name. * * @since 1.0.0 * * @param string $tool_name The tool name to search for. * @return array|null The tool config or null if not found. */ private function find_tool_by_name( string $tool_name ): ?array { $tools = $this->cpt->get_all_tools(); foreach ( $tools as $tool ) { if ( $tool['tool_name'] === $tool_name ) { $full_tool = $this->cpt->get_tool( $tool['id'] ); if ( $full_tool ) { return $full_tool['config']; } } } return null; } /** * Check if the current user has the required role. * * Administrators always pass. For other roles, an exact match is required. * * @since 1.0.0 * * @param array $required_roles The required role slugs. * @return bool */ private function user_has_required_roles( array $required_roles ): bool { $user = wp_get_current_user(); return ! empty( array_intersect( $user->roles, $required_roles ) ); } /** * Log an MCP request via the logger. * * @since 1.0.0 * * @param string $method The JSON-RPC method name. * @param string $tool_name The tool name if applicable. * @param array $request The full request payload. * @param array $response The full response payload. * @param string $status Either 'success' or 'error'. * @return void */ private function log_request( string $method, string $tool_name, array $request, array $response, string $status ): void { $this->logger->log( $method, $tool_name, $request, $response, $status ); } /** * Build a JSON-RPC success response. * * @since 1.0.0 * * @param int|string $id The JSON-RPC request ID. * @param mixed $result The result payload. * @return WP_REST_Response */ private function json_rpc_result( int|string $id, mixed $result ): WP_REST_Response { return new WP_REST_Response( array( 'jsonrpc' => '2.0', 'id' => $id, 'result' => $result, ), 200 ); } /** * Build a JSON-RPC error response. * * @since 1.0.0 * * @param int|string|null $id The JSON-RPC request ID. * @param int $code The JSON-RPC error code. * @param string $message The error message. * @return WP_REST_Response */ private function json_rpc_error( int|string|null $id, int $code, string $message ): WP_REST_Response { $status = match ( $code ) { -32700 => 400, -32600 => 400, -32601 => 400, -32602 => 400, -32603 => 500, default => 400, }; return new WP_REST_Response( array( 'jsonrpc' => '2.0', 'id' => $id, 'error' => array( 'code' => $code, 'message' => $message, ), ), $status ); } }