get_json_params(); if ( empty( $body ) ) { return $this->error_response( -32700, 'Parse error: Invalid JSON', null ); } // Validate JSON-RPC 2.0 format. if ( ! isset( $body['jsonrpc'] ) || '2.0' !== $body['jsonrpc'] ) { return $this->error_response( -32600, 'Invalid Request: Missing or invalid jsonrpc version', null ); } if ( ! isset( $body['method'] ) ) { return $this->error_response( -32600, 'Invalid Request: Missing method', null ); } $method = $body['method']; $params = isset( $body['params'] ) ? $body['params'] : array(); $id = isset( $body['id'] ) ? $body['id'] : null; // All MCP methods require authentication. initialize, tools/list, // ping and tools/call alike are gated, so every client (Claude and // the ChatGPT app builder alike) must complete OAuth before any // call — there is no unauthenticated discovery. $oauth = new Abilities_Bridge_MCP_OAuth(); $auth_result = $oauth->check_permission( $request ); if ( is_wp_error( $auth_result ) ) { // HTTP 401 + WWW-Authenticate so spec-compliant MCP clients // initiate the OAuth flow (RFC 9728 / MCP authorization). The // JSON-RPC -32000 body is retained for clients that read it. return $this->auth_challenge_response( $auth_result->get_error_message(), $id ); } // Route to appropriate handler. switch ( $method ) { case 'initialize': $result = $this->handle_initialize( $params ); break; case 'tools/list': $result = $this->handle_tools_list( $params ); break; case 'tools/call': $result = $this->handle_tools_call( $params ); break; case 'ping': $result = array( 'status' => 'ok' ); break; default: return $this->error_response( -32601, "Method not found: {$method}", $id ); } // Return successful response. return $this->success_response( $result, $id ); } catch ( Exception $e ) { // Catch any exceptions and return as JSON-RPC error. $id = isset( $body['id'] ) ? $body['id'] : null; return $this->error_response( -32603, 'Internal error: ' . $e->getMessage(), $id ); } } /** * Handle initialize request. * * @param array $params Request parameters. * @return array Initialization response. */ private function handle_initialize( $params ) { $site_url = trailingslashit( get_site_url() ); $base_url = $site_url . 'wp-json/abilities-bridge-mcp/v1'; return array( 'protocolVersion' => self::PROTOCOL_VERSION, 'serverInfo' => array( 'name' => self::SERVER_NAME, 'version' => self::SERVER_VERSION, ), 'capabilities' => array( 'tools' => new stdClass(), // Proper empty object. 'experimental' => array( 'oauth' => array( 'authorizationEndpoint' => $base_url . '/authorize', 'tokenEndpoint' => $base_url . '/oauth/token', 'grantTypes' => array( 'authorization_code', 'refresh_token' ), 'pkceRequired' => true, ), ), ), ); } /** * Get the authenticated MCP user ID from the current request context. * * @return int */ private function get_authenticated_user_id() { if ( isset( $GLOBALS['abilities_bridge_oauth_user_id'] ) ) { return (int) $GLOBALS['abilities_bridge_oauth_user_id']; } return get_current_user_id(); } /** * Run a callback with the authenticated WordPress user temporarily set. * * @param callable $callback Callback to run. * @return mixed */ private function with_authenticated_user( $callback ) { $restore_user = get_current_user_id(); $user_id = $this->get_authenticated_user_id(); if ( $user_id ) { wp_set_current_user( $user_id ); } try { return call_user_func( $callback ); } finally { wp_set_current_user( $restore_user ); } } /** * Check whether the current MCP request can see a tool. * * @param string $tool_name Tool name. * @param string|null $ability_name Optional ability name. * @return bool */ private function can_current_request_access_tool( $tool_name, $ability_name = null ) { if ( isset( $GLOBALS['abilities_bridge_oauth_scope'] ) && ! Abilities_Bridge_OAuth_Scopes::can_access_tool( $GLOBALS['abilities_bridge_oauth_scope'], $tool_name ) ) { return false; } if ( null !== $ability_name ) { if ( ! class_exists( 'Abilities_Bridge_Ability_Permissions' ) ) { return false; } $permission = $this->with_authenticated_user( function () use ( $ability_name ) { return Abilities_Bridge_Ability_Permissions::can_access_ability( $ability_name ); } ); return ! empty( $permission['allowed'] ); } return true; } /** * Convert a WordPress ability name into an MCP tool name. * * ChatGPT Apps rejects action names containing characters such as hyphens * and slashes, so keep exposed tool names to letters, numbers, and * underscores. * * @param string $ability_name WordPress ability name. * @return string MCP-safe tool name. */ private function get_tool_name_for_ability( $ability_name ) { return 'ability_' . preg_replace( '/[^A-Za-z0-9_]/', '_', $ability_name ); } /** * Resolve an MCP-safe tool name back to its WordPress ability name. * * @param string $tool_name MCP tool name. * @return string|null WordPress ability name, or null if not found. */ private function get_ability_name_for_tool( $tool_name ) { if ( ! class_exists( 'Abilities_Bridge_Ability_Permissions' ) ) { return null; } $enabled_abilities = Abilities_Bridge_Ability_Permissions::get_all_permissions( true ); foreach ( $enabled_abilities as $ability_config ) { if ( empty( $ability_config['ability_name'] ) ) { continue; } $ability_name = $ability_config['ability_name']; if ( $this->get_tool_name_for_ability( $ability_name ) === $tool_name ) { return $ability_name; } } return null; } /** * Handle tools/list request. * * @param array $params Request parameters. * @return array Tools list response. */ private function handle_tools_list( $params ) { $tools = array(); // Add memory tool if enabled. $memory_enabled = get_option( 'abilities_bridge_enable_memory', false ); $memory_consent = get_option( 'abilities_bridge_memory_consent', false ); if ( $memory_enabled && $memory_consent && $this->can_current_request_access_tool( 'memory' ) ) { $tools[] = array( 'name' => 'memory', 'description' => 'Use this to store or retrieve persistent notes inside WordPress. Prefer the read-only `view` command when checking existing memory, and only use write commands when the user explicitly wants memory updated.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'command' => array( 'type' => 'string', 'description' => 'Memory command to execute.', 'enum' => array( 'view', 'create', 'str_replace', 'insert', 'delete', 'rename' ), ), 'path' => array( 'type' => 'string', 'description' => 'Path to memory file or directory.', ), 'file_text' => array( 'type' => 'string', 'description' => 'Content for create command.', ), 'old_str' => array( 'type' => 'string', 'description' => 'String to replace in str_replace command.', ), 'new_str' => array( 'type' => 'string', 'description' => 'Replacement string in str_replace command.', ), 'insert_line' => array( 'type' => 'integer', 'description' => 'Line number for insert command.', ), 'insert_text' => array( 'type' => 'string', 'description' => 'Text to insert.', ), 'old_path' => array( 'type' => 'string', 'description' => 'Source path for rename command.', ), 'new_path' => array( 'type' => 'string', 'description' => 'Destination path for rename command.', ), 'view_range' => array( 'type' => 'array', 'description' => 'Line range for view command [start, end].', 'items' => array( 'type' => 'integer' ), ), ), 'required' => array( 'command' ), ), ); } // Add enabled abilities from WordPress Abilities API (only if API is enabled). $abilities_api_enabled = get_option( 'abilities_bridge_enable_abilities_api', false ); if ( $abilities_api_enabled && class_exists( 'Abilities_Bridge_Ability_Permissions' ) && function_exists( 'wp_get_ability' ) ) { $enabled_abilities = Abilities_Bridge_Ability_Permissions::get_all_permissions( true ); // Get enabled only. foreach ( $enabled_abilities as $ability_config ) { $ability_name = $ability_config['ability_name']; // Get the ability object from Abilities API. $ability = wp_get_ability( $ability_name ); if ( $ability ) { // Provider-ready input schema: no-input abilities are // advertised with an empty object schema, not skipped // (same defect as the chat tool builder had). $input_schema = Abilities_Bridge_Ability_Permissions::provider_input_schema( $ability->get_input_schema() ); // Fix: Ensure empty properties array encodes as {} not []. // PHP's json_encode converts empty arrays to [], but MCP protocol requires objects. if ( isset( $input_schema['properties'] ) && empty( $input_schema['properties'] ) ) { $input_schema['properties'] = new stdClass(); } $tool_name = $this->get_tool_name_for_ability( $ability_name ); if ( ! $this->can_current_request_access_tool( $tool_name, $ability_name ) ) { continue; } // Add ability to tools list. $tools[] = array( 'name' => $tool_name, 'description' => ! empty( $ability->description ) ? 'WordPress ability `' . $ability_name . '`. ' . trim( $ability->description ) . ' Use this when the user is specifically asking for this site action or site data.' : 'WordPress ability `' . $ability_name . '`. Use this when the user needs this exact site action or site data.', 'inputSchema' => $input_schema, ); } } } return array( 'tools' => $tools, ); } /** * Handle tools/call request. * * @param array $params Request parameters. * @return array Tool execution response. * @throws Exception When tool is not found or disabled. */ private function handle_tools_call( $params ) { if ( ! isset( $params['name'] ) ) { throw new Exception( 'Missing required parameter: name' ); } $tool_name = $params['name']; $arguments = isset( $params['arguments'] ) ? $params['arguments'] : array(); // Check if this is an ability call (starts with "ability_"). if ( strpos( $tool_name, 'ability_' ) === 0 ) { $ability_name = $this->get_ability_name_for_tool( $tool_name ); if ( null === $ability_name ) { throw new Exception( 'Unknown ability tool: ' . esc_html( $tool_name ) ); } // Use orchestrator to execute ability with permission checks. if ( class_exists( 'Abilities_Bridge_MCP_Orchestrator' ) ) { $orchestrator = new Abilities_Bridge_MCP_Orchestrator(); $result = $orchestrator->execute_ability_request( $ability_name, $arguments, 'MCP Server request via authenticated remote client' ); return $this->format_tool_response( $result ); } else { throw new Exception( 'Orchestrator not available for ability execution' ); } } // SCOPE ENFORCEMENT: Check if OAuth token has required scope for this tool. if ( isset( $GLOBALS['abilities_bridge_oauth_scope'] ) ) { $granted_scope = $GLOBALS['abilities_bridge_oauth_scope']; if ( ! Abilities_Bridge_OAuth_Scopes::can_access_tool( $granted_scope, $tool_name ) ) { $required_scope = Abilities_Bridge_OAuth_Scopes::get_required_scope_for_tool( $tool_name ); throw new Exception( sprintf( 'Access denied: Tool %s requires %s scope. Your token has scope: %s. Please re-authorize with the required scope.', "'" . esc_html( $tool_name ) . "'", $required_scope ? "'" . esc_html( $required_scope ) . "'" : 'appropriate', "'" . esc_html( $granted_scope ) . "'" ) ); } } // Execute the core tools. switch ( $tool_name ) { case 'memory': $result = $this->execute_memory( $arguments ); break; default: throw new Exception( 'Unknown tool: ' . esc_html( $tool_name ) ); } return $result; } /** * Execute memory tool. * * @param array $arguments Tool arguments. * @return array Formatted tool response. */ private function execute_memory( $arguments ) { // Check if memory tool is enabled. if ( ! get_option( 'abilities_bridge_enable_memory', false ) ) { return $this->format_tool_response( array( 'success' => false, 'error' => 'Memory tool is disabled. Enable it in Settings > Memory.', ) ); } // Check if user has given consent for file operations. if ( ! get_option( 'abilities_bridge_memory_consent', false ) ) { return $this->format_tool_response( array( 'success' => false, 'error' => 'Memory tool requires user consent. Please enable and accept the consent in Settings > Memory.', ) ); } $result = Abilities_Bridge_Memory_Functions::execute( $arguments ); // Log execution. Abilities_Bridge_Logger::log_tool_execution( 'memory', $arguments, $result, null, 'mcp' ); return $this->format_tool_response( $result ); } /** * Format tool response for MCP protocol. * * @param array $result WordPress function result. * @return array MCP-formatted response. */ private function format_tool_response( $result ) { // Convert WordPress function result to MCP content format. $is_error = isset( $result['success'] ) && ! $result['success']; return array( 'content' => array( array( 'type' => 'text', 'text' => wp_json_encode( $result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ), ), ), 'isError' => $is_error, ); } /** * Create success response. * * @param mixed $result Result data. * @param mixed $id Request ID (string, number, or null). * @return WP_REST_Response Response object. */ private function success_response( $result, $id = null ) { $response_data = array( 'jsonrpc' => '2.0', 'result' => $result, 'id' => $id, // Always include ID (null if not determined). ); return rest_ensure_response( $response_data ); } /** * Create error response. * * @param int $code Error code (must be integer). * @param string $message Error message (must be string). * @param mixed $id Request ID (string, number, or null). * @return WP_REST_Response Response object. */ private function error_response( $code, $message, $id = null ) { $response_data = array( 'jsonrpc' => '2.0', 'error' => array( 'code' => (int) $code, 'message' => (string) $message, ), 'id' => $id, // Always include ID (null if not determined). ); return rest_ensure_response( $response_data ); } /** * Create an authentication-required response. * * Returns the JSON-RPC -32000 error with an HTTP 401 status and a * WWW-Authenticate header pointing at the protected-resource metadata, so * spec-compliant MCP clients begin the OAuth flow (RFC 9728 / MCP * authorization). The JSON-RPC body is retained for clients that read it. * * @param string $message Error message. * @param mixed $id Request ID (string, number, or null). * @return WP_REST_Response Response object. */ private function auth_challenge_response( $message, $id = null ) { $response = $this->error_response( -32000, $message, $id ); if ( $response instanceof WP_REST_Response ) { $response->set_status( 401 ); $response->header( 'WWW-Authenticate', $this->build_www_authenticate_header() ); } return $response; } /** * Build the WWW-Authenticate header value advertising the OAuth resource. * * @return string */ private function build_www_authenticate_header() { $resource_metadata = home_url( '/.well-known/oauth-protected-resource' ); return sprintf( 'Bearer resource_metadata="%s"', esc_url_raw( $resource_metadata ) ); } }