├── includes ├── Infrastructure │ ├── ErrorHandling │ │ ├── Contracts │ │ │ └── McpErrorHandlerInterface.php │ │ ├── NullMcpErrorHandler.php │ │ └── ErrorLogMcpErrorHandler.php │ └── Observability │ │ ├── Contracts │ │ └── McpObservabilityHandlerInterface.php │ │ ├── NullMcpObservabilityHandler.php │ │ ├── ConsoleObservabilityHandler.php │ │ ├── ErrorLogMcpObservabilityHandler.php │ │ └── McpObservabilityHelperTrait.php ├── Transport │ ├── Contracts │ │ ├── McpTransportInterface.php │ │ └── McpRestTransportInterface.php │ ├── Infrastructure │ │ ├── McpTransportHelperTrait.php │ │ ├── HttpRequestContext.php │ │ ├── JsonRpcResponseBuilder.php │ │ ├── HttpSessionValidator.php │ │ ├── McpTransportContext.php │ │ ├── SessionManager.php │ │ ├── HttpRequestHandler.php │ │ └── RequestRouter.php │ └── HttpTransport.php ├── Domain │ ├── Prompts │ │ ├── Contracts │ │ │ └── McpPromptBuilderInterface.php │ │ ├── RegisterAbilityAsMcpPrompt.php │ │ └── McpPromptBuilder.php │ ├── Utils │ │ ├── SchemaTransformer.php │ │ └── McpAnnotationMapper.php │ ├── Tools │ │ ├── RegisterAbilityAsMcpTool.php │ │ ├── McpTool.php │ │ └── McpToolValidator.php │ └── Resources │ │ ├── RegisterAbilityAsMcpResource.php │ │ └── McpResourceValidator.php ├── Handlers │ ├── Initialize │ │ └── InitializeHandler.php │ ├── System │ │ └── SystemHandler.php │ ├── HandlerHelperTrait.php │ ├── Resources │ │ └── ResourcesHandler.php │ └── Prompts │ │ └── PromptsHandler.php ├── Autoloader.php ├── Abilities │ ├── McpAbilityHelperTrait.php │ ├── DiscoverAbilitiesAbility.php │ ├── GetAbilityInfoAbility.php │ └── ExecuteAbilityAbility.php ├── Plugin.php ├── Core │ ├── McpTransportFactory.php │ └── McpAdapter.php ├── Servers │ └── DefaultServerFactory.php └── Cli │ ├── McpCommand.php │ └── StdioServerBridge.php ├── package.json ├── mcp-adapter.php └── composer.json /includes/Infrastructure/ErrorHandling/Contracts/McpErrorHandlerInterface.php: -------------------------------------------------------------------------------- 1 | > $request The WordPress REST request object. 27 | * @return bool|\WP_Error True if allowed, WP_Error or false if not. 28 | */ 29 | public function check_permission( WP_REST_Request $request ); 30 | 31 | /** 32 | * Handle incoming REST requests. 33 | * 34 | * @param \WP_REST_Request> $request The WordPress REST request object. 35 | * @return \WP_REST_Response REST API response object. 36 | */ 37 | public function handle_request( WP_REST_Request $request ): \WP_REST_Response; 38 | } 39 | -------------------------------------------------------------------------------- /includes/Infrastructure/ErrorHandling/ErrorLogMcpErrorHandler.php: -------------------------------------------------------------------------------- 1 | =20.10.0", 11 | "npm": ">=10.2.3" 12 | }, 13 | "devDependencies": { 14 | "@wordpress/env": "^10.29.0", 15 | "@wordpress/scripts": "^30.22.0", 16 | "@wordpress/prettier-config": "^4.29.0" 17 | }, 18 | "files": [ 19 | "includes", 20 | "vendor", 21 | "LICENSE", 22 | "CHANGELOG.md", 23 | "README.md", 24 | "readme.txt", 25 | "mcp-adapter.php" 26 | ], 27 | "scripts": { 28 | "build": "wp-env run tests-cli --env-cwd=wp-content/plugins/$(basename \"$(pwd)\")/ composer install --no-dev --optimize-autoloader", 29 | "plugin-zip": "wp-scripts plugin-zip", 30 | "format": "wp-scripts format", 31 | "lint:php": "wp-env run tests-cli --env-cwd=wp-content/plugins/$(basename \"$(pwd)\")/ composer run-script lint:php", 32 | "lint:php:fix": "wp-env run tests-cli --env-cwd=wp-content/plugins/$(basename \"$(pwd)\")/ composer run-script lint:php:fix", 33 | "lint:php:stan": "wp-env run tests-cli --env-cwd=wp-content/plugins/$(basename \"$(pwd)\")/ composer run-script lint:php:stan", 34 | "test:php": "wp-env run tests-cli --env-cwd=wp-content/plugins/$(basename \"$(pwd)\")/ vendor/bin/phpunit -c phpunit.xml.dist", 35 | "wp-env": "wp-env" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /mcp-adapter.php: -------------------------------------------------------------------------------- 1 | > 21 | */ 22 | public \WP_REST_Request $request; 23 | 24 | /** 25 | * The HTTP method of the request. 26 | * 27 | * @var string 28 | */ 29 | public string $method; 30 | 31 | 32 | /** 33 | * The Mcp-Session-Id header from the request. 34 | * 35 | * @var string|null 36 | */ 37 | public ?string $session_id; 38 | 39 | /** 40 | * The JSON-decoded body of the request. 41 | * 42 | * @var array|null 43 | */ 44 | public ?array $body; 45 | 46 | /** 47 | * The Accept header from the request. 48 | * 49 | * @var string|null 50 | */ 51 | public ?string $accept_header; 52 | 53 | /** 54 | * Constructor. 55 | * 56 | * @param \WP_REST_Request> $request The original request object. 57 | */ 58 | public function __construct( \WP_REST_Request $request ) { 59 | $this->request = $request; 60 | $this->method = $request->get_method(); 61 | $this->session_id = $request->get_header( 'Mcp-Session-Id' ); 62 | $this->accept_header = $request->get_header( 'accept' ); 63 | $this->body = 'POST' === $this->method ? $request->get_json_params() : null; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /includes/Infrastructure/Observability/ConsoleObservabilityHandler.php: -------------------------------------------------------------------------------- 1 | $formatted_event, 41 | 'duration_ms' => $duration_ms, 42 | 'tags' => $merged_tags, 43 | 'timestamp' => gmdate( 'Y-m-d H:i:s' ), 44 | ); 45 | 46 | // Pretty print JSON for readability 47 | $json = wp_json_encode( $output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); 48 | 49 | // Output with visual separator 50 | $separator = str_repeat( '=', 80 ); 51 | $message = "\n{$separator}\n[MCP OBSERVABILITY EVENT]\n{$separator}\n{$json}\n{$separator}\n"; 52 | 53 | // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log 54 | error_log( $message ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /includes/Domain/Prompts/Contracts/McpPromptBuilderInterface.php: -------------------------------------------------------------------------------- 1 | mcp = $mcp; 33 | } 34 | 35 | /** 36 | * Handles the initialize request. 37 | * 38 | * @param int $request_id Optional. The request ID for JSON-RPC. Default 0. 39 | * 40 | * @return array Response with server capabilities and information. 41 | */ 42 | public function handle( int $request_id = 0 ): array { 43 | $server_info = array( 44 | 'name' => $this->mcp->get_server_name(), 45 | 'version' => $this->mcp->get_server_version(), 46 | ); 47 | 48 | // MCP 2025-06-18 compliant capabilities 49 | $capabilities = array( 50 | 'tools' => new stdClass(), // Empty object indicates support 51 | 'resources' => new stdClass(), // Basic resources support without listChanged/subscribe 52 | 'prompts' => new stdClass(), // Basic prompts support without listChanged 53 | 'logging' => new stdClass(), // Server supports sending log messages to client 54 | 'completions' => new stdClass(), // Server supports argument autocompletion (note: plural!) 55 | ); 56 | 57 | // Send the response according to JSON-RPC 2.0 and InitializeResult schema. 58 | return array( 59 | 'protocolVersion' => '2025-06-18', 60 | 'serverInfo' => $server_info, 61 | 'capabilities' => (object) $capabilities, 62 | 'instructions' => $this->mcp->get_server_description(), 63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /includes/Infrastructure/Observability/ErrorLogMcpObservabilityHandler.php: -------------------------------------------------------------------------------- 1 | 80 |
81 |

82 | 83 |

84 |
85 | get_meta(); 37 | $is_public_mcp = $meta['mcp']['public'] ?? false; 38 | 39 | if ( ! $is_public_mcp ) { 40 | return new \WP_Error( 41 | 'ability_not_public_mcp', 42 | sprintf( 'Ability "%s" is not exposed via MCP (mcp.public!=true)', $ability_name ) 43 | ); 44 | } 45 | 46 | return true; 47 | } 48 | 49 | /** 50 | * Checks if ability is publicly exposed via MCP (simple boolean version). 51 | * 52 | * This is a simplified version that returns only boolean values, 53 | * useful for filtering operations where WP_Error handling isn't needed. 54 | * 55 | * @param \WP_Ability $ability The ability object to check. 56 | * 57 | * @return bool True if publicly exposed, false otherwise. 58 | */ 59 | protected static function is_ability_mcp_public( \WP_Ability $ability ): bool { 60 | $meta = $ability->get_meta(); 61 | return (bool) ( $meta['mcp']['public'] ?? false ); 62 | } 63 | 64 | /** 65 | * Gets the MCP type of an ability. 66 | * 67 | * Returns the type specified in meta.mcp.type, defaulting to 'tool' if not specified. 68 | * 69 | * @param \WP_Ability $ability The ability object to check. 70 | * 71 | * @return string The MCP type ('tool', 'resource', or 'prompt'). Defaults to 'tool'. 72 | */ 73 | protected static function get_ability_mcp_type( \WP_Ability $ability ): string { 74 | $meta = $ability->get_meta(); 75 | $type = $meta['mcp']['type'] ?? 'tool'; 76 | 77 | // Validate type is one of the allowed values 78 | if ( ! in_array( $type, array( 'tool', 'resource', 'prompt' ), true ) ) { 79 | return 'tool'; 80 | } 81 | 82 | return $type; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /includes/Handlers/System/SystemHandler.php: -------------------------------------------------------------------------------- 1 | McpErrorFactory::missing_parameter( $request_id, 'level' )['error'] ); 41 | } 42 | 43 | // @todo: Implement logging level setting logic here. 44 | 45 | return array(); 46 | } 47 | 48 | /** 49 | * Handles the completion/complete request. 50 | * 51 | * @param int $request_id Optional. The request ID for JSON-RPC. Default 0. 52 | * 53 | * @return array Completion response array. 54 | */ 55 | public function complete( int $request_id = 0 ): array { 56 | // Implement completion logic here. 57 | 58 | return array(); 59 | } 60 | 61 | /** 62 | * Handles the roots/list request. 63 | * 64 | * @param int $request_id Optional. The request ID for JSON-RPC. Default 0. 65 | * 66 | * @return array Response with roots list. 67 | */ 68 | public function list_roots( int $request_id = 0 ): array { 69 | // Implement roots listing logic here. 70 | $roots = array(); 71 | 72 | return array( 73 | 'roots' => $roots, 74 | ); 75 | } 76 | 77 | /** 78 | * Handles method not found errors. 79 | * 80 | * @param array $params Request parameters. 81 | * @param int $request_id Optional. The request ID for JSON-RPC. Default 0. 82 | * 83 | * @return array Response with method not found error. 84 | */ 85 | public function method_not_found( array $params, int $request_id = 0 ): array { 86 | $method = $params['method'] ?? 'unknown'; 87 | 88 | return array( 'error' => McpErrorFactory::method_not_found( $request_id, $method )['error'] ); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /includes/Plugin.php: -------------------------------------------------------------------------------- 1 | setup(); 36 | 37 | /** 38 | * Fires after the main plugin class has been initialized. 39 | * 40 | * @param self $instance The main plugin class instance. 41 | */ 42 | do_action( 'wp_mcp_init', self::$instance ); 43 | } 44 | 45 | return self::$instance; 46 | } 47 | 48 | /** 49 | * Sets up the plugin. 50 | */ 51 | private function setup(): void { 52 | // Bail if dependencies are not met. 53 | if ( ! $this->has_dependencies() ) { 54 | return; 55 | } 56 | 57 | McpAdapter::instance(); 58 | } 59 | 60 | /** 61 | * Checks if all required dependencies are available. 62 | * 63 | * Will log an admin notice if dependencies are missing. 64 | * 65 | * @return bool True if all dependencies are met, false otherwise. 66 | */ 67 | private function has_dependencies(): bool { 68 | // Check if Abilities API is available. 69 | if ( ! function_exists( 'wp_register_ability' ) ) { 70 | add_action( 71 | 'admin_notices', 72 | static function () { 73 | wp_admin_notice( 74 | __( 'Abilities API not available (wp_register_ability function not found)', 'mcp-adapter' ), 75 | array( 76 | 'type' => 'error', 77 | 'dismiss' => false, 78 | ), 79 | ); 80 | } 81 | ); 82 | 83 | return false; 84 | } 85 | 86 | return true; 87 | } 88 | 89 | /** 90 | * Prevents the class from being cloned. 91 | */ 92 | public function __clone() { 93 | _doing_it_wrong( 94 | __FUNCTION__, 95 | sprintf( 96 | // translators: %s: Class name. 97 | esc_html__( 'The %s class should not be cloned.', 'mcp-adapter' ), 98 | esc_html( self::class ), 99 | ), 100 | '0.1.0' 101 | ); 102 | } 103 | 104 | /** 105 | * Prevents the class from being deserialized. 106 | */ 107 | public function __wakeup() { 108 | _doing_it_wrong( 109 | __FUNCTION__, 110 | sprintf( 111 | // translators: %s: Class name. 112 | esc_html__( 'De-serializing instances of %s is not allowed.', 'mcp-adapter' ), 113 | esc_html( self::class ), 114 | ), 115 | '0.1.0' 116 | ); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /includes/Domain/Utils/SchemaTransformer.php: -------------------------------------------------------------------------------- 1 | |null $schema The JSON schema to transform. 29 | * @param string $wrapper_key Property name to use when wrapping non-object schemas. 30 | * 31 | * @return array Array containing 'schema', 'was_transformed' (bool), and 'wrapper_property' when transformed. 32 | */ 33 | public static function transform_to_object_schema( ?array $schema, string $wrapper_key = 'input' ): array { 34 | // Handle null or empty schema - return minimal object schema 35 | if ( empty( $schema ) ) { 36 | return array( 37 | 'schema' => array( 38 | 'type' => 'object', 39 | 'additionalProperties' => false, 40 | ), 41 | 'was_transformed' => false, // Empty schema wasn't really transformed 42 | 'wrapper_property' => null, 43 | ); 44 | } 45 | 46 | // If no type is specified, just return it as-is. It will fail MCP validation later because MCP requires an explicit type: "object" 47 | if ( ! isset( $schema['type'] ) ) { 48 | return array( 49 | 'schema' => $schema, 50 | 'was_transformed' => false, 51 | 'wrapper_property' => null, 52 | ); 53 | } 54 | 55 | // If already an object type, return as-is 56 | if ( 'object' === $schema['type'] ) { 57 | return array( 58 | 'schema' => $schema, 59 | 'was_transformed' => false, 60 | 'wrapper_property' => null, 61 | ); 62 | } 63 | 64 | // Transform flattened schema to object format 65 | return array( 66 | 'schema' => self::wrap_in_object( $schema, $wrapper_key ), 67 | 'was_transformed' => true, 68 | 'wrapper_property' => $wrapper_key, 69 | ); 70 | } 71 | 72 | /** 73 | * Wrap a flattened schema in an object structure. 74 | * 75 | * Creates an object schema with a single property (named by $wrapper_key) that 76 | * contains the original flattened schema. 77 | * 78 | * @param array $schema The flattened schema to wrap. 79 | * @param string $wrapper_key Property name to wrap the value under. 80 | * 81 | * @return array The wrapped object schema. 82 | */ 83 | private static function wrap_in_object( array $schema, string $wrapper_key ): array { 84 | return array( 85 | 'type' => 'object', 86 | 'properties' => array( 87 | $wrapper_key => $schema, 88 | ), 89 | 'required' => array( $wrapper_key ), 90 | ); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /includes/Transport/Infrastructure/JsonRpcResponseBuilder.php: -------------------------------------------------------------------------------- 1 | '2.0', 31 | 'id' => $request_id, 32 | // Make sure the result is an object (not an array) 33 | 'result' => (object) $result, 34 | ); 35 | } 36 | 37 | /** 38 | * Create a JSON-RPC 2.0 error response. 39 | * 40 | * @param mixed $request_id The request ID from the original JSON-RPC request (string, number, or null). 41 | * @param array $error The error array with 'code', 'message', and optional 'data'. 42 | * 43 | * @return array The formatted JSON-RPC error response. 44 | */ 45 | public static function create_error_response( $request_id, array $error ): array { 46 | return array( 47 | 'jsonrpc' => '2.0', 48 | 'id' => $request_id, 49 | 'error' => $error, 50 | ); 51 | } 52 | 53 | /** 54 | * Process multiple MCP messages and format the response correctly. 55 | * 56 | * Handles both batch requests (array of messages) and single requests, 57 | * returning the appropriate response format per JSON-RPC 2.0 specification. 58 | * 59 | * @param array $messages Array of JSON-RPC messages to process. 60 | * @param bool $is_batch_request Whether the original request was a batch. 61 | * @param callable $processor Callback function to process each individual message. 62 | * Should accept (array $message) and return array $response. 63 | * 64 | * @return array|null The formatted response (array for batch, single response for non-batch). 65 | */ 66 | public static function process_messages( array $messages, bool $is_batch_request, callable $processor ): ?array { 67 | $results = array(); 68 | 69 | foreach ( $messages as $message ) { 70 | $response = call_user_func( $processor, $message ); 71 | if ( null === $response ) { 72 | continue; 73 | } 74 | 75 | $results[] = $response; 76 | } 77 | 78 | // Return response format based on original request format (JSON-RPC 2.0 spec) 79 | // If the request was a batch, response MUST be an array, even if only one result 80 | return $is_batch_request ? $results : ( $results[0] ?? null ); 81 | } 82 | 83 | /** 84 | * Determine if a request body represents a batch request. 85 | * 86 | * Per JSON-RPC 2.0 specification, a batch request is an array with at least one element. 87 | * 88 | * @param mixed $body The decoded request body. 89 | * 90 | * @return bool True if this is a batch request. 91 | */ 92 | public static function is_batch_request( $body ): bool { 93 | return is_array( $body ) && isset( $body[0] ); 94 | } 95 | 96 | /** 97 | * Normalize request body to an array of messages. 98 | * 99 | * Converts single messages to an array for uniform processing. 100 | * 101 | * @param mixed $body The decoded request body. 102 | * 103 | * @return array Array of messages for processing. 104 | */ 105 | public static function normalize_messages( $body ): array { 106 | return self::is_batch_request( $body ) ? $body : array( $body ); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /includes/Handlers/HandlerHelperTrait.php: -------------------------------------------------------------------------------- 1 | $request_id, 47 | 'error' => array( 48 | 'code' => $code, 49 | 'message' => $message, 50 | ), 51 | ); 52 | } 53 | 54 | /** 55 | * Extracts error array from McpErrorFactory response. 56 | * 57 | * McpErrorFactory methods return ['error' => [...]] but handlers 58 | * often need just the error array itself. 59 | * 60 | * @param array $factory_response Response from McpErrorFactory method. 61 | * 62 | * @return array Error array (without wrapping 'error' key). 63 | */ 64 | protected function extract_error( array $factory_response ): array { 65 | return $factory_response['error'] ?? $factory_response; 66 | } 67 | 68 | /** 69 | * Creates missing parameter error response. 70 | * 71 | * @param string $param_name Missing parameter name. 72 | * @param int $request_id Optional. Request ID for JSON-RPC. Default 0. 73 | * 74 | * @return array Error response array. 75 | */ 76 | protected function missing_parameter_error( string $param_name, int $request_id = 0 ): array { 77 | return array( 'error' => McpErrorFactory::missing_parameter( $request_id, $param_name )['error'] ); 78 | } 79 | 80 | /** 81 | * Creates permission denied error response. 82 | * 83 | * @param string $denied_resource Resource that was denied. 84 | * @param int $request_id Optional. Request ID for JSON-RPC. Default 0. 85 | * 86 | * @return array Error response array. 87 | */ 88 | protected function permission_denied_error( string $denied_resource, int $request_id = 0 ): array { 89 | return array( 'error' => McpErrorFactory::permission_denied( $request_id, 'Access denied for: ' . $denied_resource )['error'] ); 90 | } 91 | 92 | /** 93 | * Creates internal error response. 94 | * 95 | * @param string $message Error message. 96 | * @param int $request_id Optional. Request ID for JSON-RPC. Default 0. 97 | * 98 | * @return array Error response array. 99 | */ 100 | protected function internal_error( string $message, int $request_id = 0 ): array { 101 | return array( 'error' => McpErrorFactory::internal_error( $request_id, $message )['error'] ); 102 | } 103 | 104 | /** 105 | * Creates a standardized success response. 106 | * 107 | * @param mixed $data Response data. 108 | * 109 | * @return array Success response array. 110 | */ 111 | protected function create_success_response( $data ): array { 112 | return array( 113 | 'result' => $data, 114 | ); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /includes/Infrastructure/Observability/McpObservabilityHelperTrait.php: -------------------------------------------------------------------------------- 1 | 'arguments', 26 | \Error::class => 'system', 27 | \InvalidArgumentException::class => 'validation', 28 | \LogicException::class => 'logic', 29 | \RuntimeException::class => 'execution', 30 | \TypeError::class => 'type', 31 | ); 32 | 33 | /** 34 | * Get default tags that should be included with all metrics. 35 | * 36 | * @return array 37 | */ 38 | public static function get_default_tags(): array { 39 | return array( 40 | 'site_id' => function_exists( 'get_current_blog_id' ) ? get_current_blog_id() : 0, 41 | 'user_id' => function_exists( 'get_current_user_id' ) ? get_current_user_id() : 0, 42 | 'timestamp' => time(), 43 | ); 44 | } 45 | 46 | /** 47 | * Sanitize tags to ensure they are safe for logging and don't contain sensitive data. 48 | * 49 | * @param array $tags The tags to sanitize. 50 | * 51 | * @return array 52 | */ 53 | public static function sanitize_tags( array $tags ): array { 54 | $sanitized = array(); 55 | 56 | foreach ( $tags as $key => $value ) { 57 | // Convert to string and limit length to prevent log bloat. 58 | $key = substr( (string) $key, 0, 64 ); 59 | 60 | // Convert value to string, handling null specially. 61 | if ( null === $value ) { 62 | $value = ''; 63 | } elseif ( is_scalar( $value ) ) { 64 | $value = (string) $value; 65 | } else { 66 | $value = wp_json_encode( $value ); 67 | // wp_json_encode can return false on failure, ensure we have a string. 68 | if ( false === $value ) { 69 | $value = ''; 70 | } 71 | } 72 | 73 | // Remove potentially sensitive information patterns. 74 | $value = preg_replace( '/\b(?:password|token|key|secret|auth)\b/i', '[REDACTED]', $value ); 75 | 76 | $sanitized[ $key ] = $value; 77 | } 78 | 79 | return $sanitized; 80 | } 81 | 82 | /** 83 | * Format metric name to follow consistent naming conventions. 84 | * 85 | * @param string $metric The raw metric name. 86 | * 87 | * @return string 88 | */ 89 | public static function format_metric_name( string $metric ): string { 90 | // Ensure metric starts with 'mcp.' prefix. 91 | if ( ! str_starts_with( $metric, 'mcp.' ) ) { 92 | $metric = 'mcp.' . $metric; 93 | } 94 | 95 | // Convert to lowercase and replace spaces/special chars with dots. 96 | $metric = strtolower( $metric ); 97 | $metric = (string) preg_replace( '/[^a-z0-9_\.]/', '.', $metric ); 98 | $metric = (string) preg_replace( '/\.+/', '.', $metric ); // Remove duplicate dots. 99 | // Remove leading/trailing dots. 100 | 101 | return trim( $metric, '.' ); 102 | } 103 | 104 | /** 105 | * Merge default tags with provided tags. 106 | * 107 | * @param array $tags The user-provided tags. 108 | * 109 | * @return array 110 | */ 111 | public static function merge_tags( array $tags ): array { 112 | $default_tags = self::get_default_tags(); 113 | $merged_tags = array_merge( $default_tags, $tags ); 114 | 115 | return self::sanitize_tags( $merged_tags ); 116 | } 117 | 118 | /** 119 | * Categorize an exception into a general error category. 120 | * 121 | * @param \Throwable $exception The exception to categorize. 122 | * 123 | * @return string 124 | */ 125 | public static function categorize_error( \Throwable $exception ): string { 126 | return self::$error_categories[ get_class( $exception ) ] ?? 'unknown'; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /includes/Transport/Infrastructure/HttpSessionValidator.php: -------------------------------------------------------------------------------- 1 | session_id; 35 | if ( ! $session_id ) { 36 | return McpErrorFactory::invalid_request( 0, 'Missing Mcp-Session-Id header' ); 37 | } 38 | 39 | // Check user authentication 40 | $user_id = get_current_user_id(); 41 | if ( ! $user_id ) { 42 | return McpErrorFactory::unauthorized( 0, 'User not authenticated' ); 43 | } 44 | 45 | // Validate session using SessionManager 46 | if ( ! SessionManager::validate_session( $user_id, $session_id ) ) { 47 | return McpErrorFactory::invalid_params( 0, 'Invalid or expired session' ); 48 | } 49 | 50 | return true; 51 | } 52 | 53 | /** 54 | * Validate session header presence in HTTP request. 55 | * 56 | * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext $context The HTTP request context. 57 | * 58 | * @return string|array Session ID on success, error array on failure. 59 | */ 60 | public static function validate_session_header( HttpRequestContext $context ) { 61 | $session_id = $context->session_id; 62 | 63 | if ( ! $session_id ) { 64 | return McpErrorFactory::invalid_request( 0, 'Missing Mcp-Session-Id header' ); 65 | } 66 | 67 | return $session_id; 68 | } 69 | 70 | /** 71 | * Create a new session for the current user with HTTP context awareness. 72 | * 73 | * Validates user authentication and creates session, providing better error 74 | * context than direct SessionManager calls. 75 | * 76 | * @param array $params The client parameters from initialize request. 77 | * 78 | * @return string|array Session ID on success, error array on failure. 79 | */ 80 | public static function create_session( array $params = array() ) { 81 | $user_id = get_current_user_id(); 82 | if ( ! $user_id ) { 83 | return McpErrorFactory::unauthorized( 0, 'User authentication required for session creation' ); 84 | } 85 | 86 | $session_id = SessionManager::create_session( $user_id, $params ); 87 | 88 | if ( ! $session_id ) { 89 | return McpErrorFactory::internal_error( 0, 'Failed to create session' ); 90 | } 91 | 92 | return $session_id; 93 | } 94 | 95 | /** 96 | * Terminate a session with full HTTP context validation. 97 | * 98 | * Performs complete validation workflow for session termination including 99 | * header validation, user authentication, and session cleanup. 100 | * 101 | * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext $context The HTTP request context. 102 | * 103 | * @return array|true Returns true on success, error array on failure. 104 | */ 105 | public static function terminate_session( HttpRequestContext $context ) { 106 | // Validate session header 107 | $session_id = $context->session_id; 108 | if ( ! $session_id ) { 109 | return McpErrorFactory::invalid_request( 0, 'Missing Mcp-Session-Id header' ); 110 | } 111 | 112 | // Validate user authentication 113 | $user_id = get_current_user_id(); 114 | if ( ! $user_id ) { 115 | return McpErrorFactory::unauthorized( 0, 'User not authenticated' ); 116 | } 117 | 118 | // Terminate the session 119 | SessionManager::delete_session( $user_id, $session_id ); 120 | 121 | return true; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /includes/Core/McpTransportFactory.php: -------------------------------------------------------------------------------- 1 | mcp_server = $mcp_server; 38 | } 39 | 40 | /** 41 | * Initialize MCP transports for the server. 42 | * 43 | * @param array $mcp_transports Array of MCP transport class names to initialize. 44 | */ 45 | public function initialize_transports( array $mcp_transports ): void { 46 | foreach ( $mcp_transports as $mcp_transport ) { 47 | // Check if class exists 48 | if ( ! class_exists( $mcp_transport ) ) { 49 | _doing_it_wrong( 50 | __FUNCTION__, 51 | sprintf( 52 | /* translators: %s: Transport class name */ 53 | esc_html__( 'Transport class "%s" does not exist. Make sure the class is properly autoloaded or included.', 'mcp-adapter' ), 54 | esc_html( $mcp_transport ) 55 | ), 56 | '0.1.0' 57 | ); 58 | // Log error and continue processing other transports 59 | $this->mcp_server->get_error_handler()->log( 60 | sprintf( 'Transport class "%s" does not exist.', $mcp_transport ), 61 | array( 'McpTransportFactory::initialize_transports' ) 62 | ); 63 | continue; 64 | } 65 | 66 | // Check for interface implementation 67 | if ( ! in_array( McpTransportInterface::class, class_implements( $mcp_transport ) ?: array(), true ) ) { 68 | _doing_it_wrong( 69 | __FUNCTION__, 70 | sprintf( 71 | /* translators: %s: Transport class name */ 72 | esc_html__( 'Transport class "%s" must implement McpTransportInterface. Check your transport implementation.', 'mcp-adapter' ), 73 | esc_html( $mcp_transport ) 74 | ), 75 | '0.1.0' 76 | ); 77 | // Log error and continue processing other transports 78 | $this->mcp_server->get_error_handler()->log( 79 | sprintf( 'MCP transport class "%s" must implement the McpTransportInterface.', $mcp_transport ), 80 | array( 'McpTransportFactory::initialize_transports' ) 81 | ); 82 | continue; 83 | } 84 | 85 | // Interface-based instantiation with dependency injection 86 | $context = $this->create_transport_context(); 87 | new $mcp_transport( $context ); 88 | } 89 | } 90 | 91 | /** 92 | * Create transport context with all required dependencies. 93 | * 94 | * @return \WP\MCP\Transport\Infrastructure\McpTransportContext 95 | */ 96 | public function create_transport_context(): McpTransportContext { 97 | // Create handlers 98 | $initialize_handler = new InitializeHandler( $this->mcp_server ); 99 | $tools_handler = new ToolsHandler( $this->mcp_server ); 100 | $resources_handler = new ResourcesHandler( $this->mcp_server ); 101 | $prompts_handler = new PromptsHandler( $this->mcp_server ); 102 | $system_handler = new SystemHandler(); 103 | 104 | // Create the context - the router will be created automatically 105 | return new McpTransportContext( 106 | array( 107 | 'mcp_server' => $this->mcp_server, 108 | 'initialize_handler' => $initialize_handler, 109 | 'tools_handler' => $tools_handler, 110 | 'resources_handler' => $resources_handler, 111 | 'prompts_handler' => $prompts_handler, 112 | 'system_handler' => $system_handler, 113 | 'observability_handler' => $this->mcp_server->get_observability_handler(), 114 | 'error_handler' => $this->mcp_server->get_error_handler(), 115 | 'transport_permission_callback' => $this->mcp_server->get_transport_permission_callback(), 116 | ) 117 | ); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /includes/Domain/Tools/RegisterAbilityAsMcpTool.php: -------------------------------------------------------------------------------- 1 | get_tool(); 51 | } 52 | 53 | /** 54 | * Constructor. 55 | * 56 | * @param \WP_Ability $ability The ability. 57 | * @param \WP\MCP\Core\McpServer $mcp_server The MCP server. 58 | */ 59 | private function __construct( WP_Ability $ability, McpServer $mcp_server ) { 60 | $this->mcp_server = $mcp_server; 61 | $this->ability = $ability; 62 | } 63 | 64 | /** 65 | * Get the MCP tool data array. 66 | * 67 | * @return array 68 | */ 69 | private function get_data(): array { 70 | // Transform input schema to MCP-compatible object format 71 | $input_transform = SchemaTransformer::transform_to_object_schema( 72 | $this->ability->get_input_schema() 73 | ); 74 | 75 | $tool_data = array( 76 | 'ability' => $this->ability->get_name(), 77 | 'name' => str_replace( '/', '-', trim( $this->ability->get_name() ) ), 78 | 'description' => trim( $this->ability->get_description() ), 79 | 'inputSchema' => $input_transform['schema'], 80 | ); 81 | 82 | // Add optional title from ability label. 83 | $label = $this->ability->get_label(); 84 | $label = trim( $label ); 85 | if ( ! empty( $label ) ) { 86 | $tool_data['title'] = $label; 87 | } 88 | 89 | // Add optional output schema, transformed to object format if needed. 90 | $output_schema = $this->ability->get_output_schema(); 91 | $output_transform = null; 92 | if ( ! empty( $output_schema ) ) { 93 | $output_transform = SchemaTransformer::transform_to_object_schema( 94 | $output_schema, 95 | 'result' 96 | ); 97 | $tool_data['outputSchema'] = $output_transform['schema']; 98 | } 99 | 100 | // Map annotations from ability meta to MCP format using unified mapper. 101 | $ability_meta = $this->ability->get_meta(); 102 | if ( ! empty( $ability_meta['annotations'] ) && is_array( $ability_meta['annotations'] ) ) { 103 | $mcp_annotations = McpAnnotationMapper::map( $ability_meta['annotations'], 'tool' ); 104 | if ( ! empty( $mcp_annotations ) ) { 105 | $tool_data['annotations'] = $mcp_annotations; 106 | } 107 | } 108 | 109 | // Set annotations.title from label if annotations exist but don't have a title. 110 | if ( ! empty( $label ) && isset( $tool_data['annotations'] ) && ! isset( $tool_data['annotations']['title'] ) ) { 111 | $tool_data['annotations']['title'] = $label; 112 | } 113 | 114 | // Store transformation metadata as internal metadata (stripped before responding to clients). 115 | if ( $input_transform['was_transformed'] || ( $output_transform && $output_transform['was_transformed'] ) ) { 116 | $tool_data['_metadata'] = array(); 117 | 118 | if ( $input_transform['was_transformed'] ) { 119 | $tool_data['_metadata']['_input_schema_transformed'] = true; 120 | $tool_data['_metadata']['_input_schema_wrapper'] = $input_transform['wrapper_property'] ?? 'input'; 121 | } 122 | 123 | if ( $output_transform && $output_transform['was_transformed'] ) { 124 | $tool_data['_metadata']['_output_schema_transformed'] = true; 125 | $tool_data['_metadata']['_output_schema_wrapper'] = $output_transform['wrapper_property'] ?? 'result'; 126 | } 127 | } 128 | 129 | return $tool_data; 130 | } 131 | 132 | /** 133 | * Get the MCP tool instance. 134 | * 135 | * @return \WP\MCP\Domain\Tools\McpTool|\WP_Error The validated MCP tool instance or WP_Error if validation fails. 136 | */ 137 | private function get_tool() { 138 | return McpTool::from_array( $this->get_data(), $this->mcp_server ); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /includes/Servers/DefaultServerFactory.php: -------------------------------------------------------------------------------- 1 | 'mcp-adapter-default-server', 45 | 'server_route_namespace' => 'mcp', 46 | 'server_route' => 'mcp-adapter-default-server', 47 | 'server_name' => 'MCP Adapter Default Server', 48 | 'server_description' => 'Default MCP server for WordPress abilities discovery and execution', 49 | 'server_version' => 'v1.0.0', 50 | 'mcp_transports' => array( HttpTransport::class ), 51 | 'error_handler' => ErrorLogMcpErrorHandler::class, 52 | 'observability_handler' => NullMcpObservabilityHandler::class, 53 | 'tools' => array( 54 | 'mcp-adapter/discover-abilities', 55 | 'mcp-adapter/get-ability-info', 56 | 'mcp-adapter/execute-ability', 57 | ), 58 | 'resources' => $auto_discovered_resources, 59 | 'prompts' => $auto_discovered_prompts, 60 | ); 61 | 62 | // Apply WordPress filter for customization 63 | $config = apply_filters( 'mcp_adapter_default_server_config', $wordpress_defaults ); 64 | 65 | // Ensure config is an array and merge with defaults 66 | if ( ! is_array( $config ) ) { 67 | $config = $wordpress_defaults; 68 | } 69 | $config = wp_parse_args( $config, $wordpress_defaults ); 70 | 71 | // Use McpAdapter to create the server with full validation 72 | $adapter = McpAdapter::instance(); 73 | $result = $adapter->create_server( 74 | $config['server_id'], 75 | $config['server_route_namespace'], 76 | $config['server_route'], 77 | $config['server_name'], 78 | $config['server_description'], 79 | $config['server_version'], 80 | $config['mcp_transports'], 81 | $config['error_handler'], 82 | $config['observability_handler'], 83 | $config['tools'], 84 | $config['resources'], 85 | $config['prompts'] 86 | ); 87 | 88 | // Log error if server creation failed, but don't halt execution. 89 | // This allows other servers to be registered even if default server fails. 90 | if ( ! is_wp_error( $result ) ) { 91 | return; 92 | } 93 | 94 | _doing_it_wrong( 95 | __METHOD__, 96 | sprintf( 97 | 'MCP Adapter: Failed to create default server. Error: %s (Code: %s)', 98 | esc_html( $result->get_error_message() ), 99 | esc_html( (string) $result->get_error_code() ) 100 | ), 101 | 'n.e.x.t' 102 | ); 103 | } 104 | 105 | /** 106 | * Discover abilities by MCP type. 107 | * 108 | * Scans all registered abilities and returns those with the specified type 109 | * and public MCP exposure. 110 | * 111 | * @param string $type The MCP type to filter by ('tool', 'resource', or 'prompt'). 112 | * 113 | * @return array Array of ability names matching the specified type. 114 | */ 115 | private static function discover_abilities_by_type( string $type ): array { 116 | $abilities = wp_get_abilities(); 117 | $filtered = array(); 118 | 119 | foreach ( $abilities as $ability ) { 120 | $ability_name = $ability->get_name(); 121 | $meta = $ability->get_meta(); 122 | 123 | // Skip if not publicly exposed 124 | if ( ! ( $meta['mcp']['public'] ?? false ) ) { 125 | continue; 126 | } 127 | 128 | // Get the type (defaults to 'tool' if not specified) 129 | $ability_type = $meta['mcp']['type'] ?? 'tool'; 130 | 131 | // Add to filtered list if type matches 132 | if ( $ability_type !== $type ) { 133 | continue; 134 | } 135 | 136 | $filtered[] = $ability_name; 137 | } 138 | 139 | return $filtered; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /includes/Transport/HttpTransport.php: -------------------------------------------------------------------------------- 1 | request_handler = new HttpRequestHandler( $transport_context ); 43 | add_action( 'rest_api_init', array( $this, 'register_routes' ), 16 ); 44 | } 45 | 46 | /** 47 | * Register MCP HTTP routes 48 | */ 49 | public function register_routes(): void { 50 | // Get server info from request handler's transport context 51 | $server = $this->request_handler->transport_context->mcp_server; 52 | 53 | // Single endpoint for MCP communication (POST, GET for SSE, DELETE for session termination) 54 | register_rest_route( 55 | $server->get_server_route_namespace(), 56 | $server->get_server_route(), 57 | array( 58 | 'methods' => array( 'POST', 'GET', 'DELETE' ), 59 | 'callback' => array( $this, 'handle_request' ), 60 | 'permission_callback' => array( $this, 'check_permission' ), 61 | ) 62 | ); 63 | } 64 | 65 | /** 66 | * Check if the user has permission to access the MCP API 67 | * 68 | * @param \WP_REST_Request> $request The request object. 69 | * 70 | * @return bool True if the user has permission, false otherwise. 71 | */ 72 | public function check_permission( \WP_REST_Request $request ) { 73 | $context = new HttpRequestContext( $request ); 74 | 75 | // Check permission using callback or default 76 | $transport_context = $this->request_handler->transport_context; 77 | 78 | if ( null !== $transport_context->transport_permission_callback ) { 79 | try { 80 | $result = call_user_func( $transport_context->transport_permission_callback, $context->request ); 81 | 82 | // Handle WP_Error returns 83 | if ( ! is_wp_error( $result ) ) { 84 | // Return boolean result directly 85 | return $result; 86 | } 87 | 88 | // Log the error and fall back to default permission 89 | $this->request_handler->transport_context->error_handler->log( 90 | 'Permission callback returned WP_Error: ' . $result->get_error_message(), 91 | array( 'HttpTransport::check_permission' ) 92 | ); 93 | // Fall through to default permission check 94 | } catch ( \Throwable $e ) { 95 | // Log the error using the error handler, and fall back to default permission 96 | $this->request_handler->transport_context->error_handler->log( 'Error in transport permission callback: ' . $e->getMessage(), array( 'HttpTransport::check_permission' ) ); 97 | } 98 | } 99 | $user_capability = apply_filters( 'mcp_adapter_default_transport_permission_user_capability', 'read', $context ); 100 | 101 | // Validate that the filtered capability is a non-empty string 102 | if ( ! is_string( $user_capability ) || empty( $user_capability ) ) { 103 | $user_capability = 'read'; 104 | } 105 | 106 | $user_has_capability = current_user_can( $user_capability ); // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is filtered and defaults to 'read' 107 | 108 | if ( ! $user_has_capability ) { 109 | $user_id = get_current_user_id(); 110 | $this->request_handler->transport_context->error_handler->log( 111 | sprintf( 'Permission denied for MCP API access. User ID %d does not have capability "%s"', $user_id, $user_capability ), 112 | array( 'HttpTransport::check_permission' ) 113 | ); 114 | } 115 | 116 | return $user_has_capability; 117 | } 118 | 119 | /** 120 | * Handle HTTP requests according to MCP 2025-06-18 specification 121 | * 122 | * @param \WP_REST_Request> $request The request object. 123 | * 124 | * @return \WP_REST_Response 125 | */ 126 | public function handle_request( \WP_REST_Request $request ): \WP_REST_Response { 127 | $context = new HttpRequestContext( $request ); 128 | 129 | return $this->request_handler->handle_request( $context ); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /includes/Transport/Infrastructure/McpTransportContext.php: -------------------------------------------------------------------------------- 1 | $value ) { 133 | $this->$name = $value; 134 | } 135 | 136 | // If request_router is provided, we're done 137 | if ( isset( $properties['request_router'] ) ) { 138 | return; 139 | } 140 | 141 | // Create request_router if not provided 142 | $this->request_router = new RequestRouter( $this ); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /includes/Abilities/DiscoverAbilitiesAbility.php: -------------------------------------------------------------------------------- 1 | 'Discover Abilities', 36 | 'description' => 'Discover all available WordPress abilities in the system. Returns a list of all registered abilities with their basic information.', 37 | 'category' => 'mcp-adapter', 38 | 'output_schema' => array( 39 | 'type' => 'object', 40 | 'properties' => array( 41 | 'abilities' => array( 42 | 'type' => 'array', 43 | 'items' => array( 44 | 'type' => 'object', 45 | 'properties' => array( 46 | 'name' => array( 'type' => 'string' ), 47 | 'label' => array( 'type' => 'string' ), 48 | 'description' => array( 'type' => 'string' ), 49 | ), 50 | 'required' => array( 'name', 'label', 'description' ), 51 | ), 52 | ), 53 | ), 54 | 'required' => array( 'abilities' ), 55 | ), 56 | 'permission_callback' => array( self::class, 'check_permission' ), 57 | 'execute_callback' => array( self::class, 'execute' ), 58 | 'meta' => array( 59 | 'annotations' => array( 60 | 'readonly' => true, 61 | 'destructive' => false, 62 | 'idempotent' => true, 63 | ), 64 | ), 65 | ) 66 | ); 67 | } 68 | 69 | /** 70 | * Check permissions for discovering abilities. 71 | * 72 | * Validates user capabilities and caller identity. 73 | * 74 | * @param array $input Input parameters (unused for this ability). 75 | * 76 | * @return bool|\WP_Error True if the user has permission to discover abilities. 77 | * @phpstan-return bool|\WP_Error 78 | */ 79 | public static function check_permission( $input = array() ) { 80 | // Validate user authentication and capabilities 81 | return self::validate_user_access(); 82 | } 83 | 84 | /** 85 | * Validate user authentication and basic capabilities for discover abilities. 86 | * 87 | * @return bool|\WP_Error True if valid, WP_Error if validation fails. 88 | */ 89 | private static function validate_user_access() { 90 | // Verify caller identity - ensure user is authenticated 91 | if ( ! is_user_logged_in() ) { 92 | return new \WP_Error( 'authentication_required', 'User must be authenticated to access this ability' ); 93 | } 94 | 95 | // Check basic capability requirement - allow customization via filter 96 | $required_capability = apply_filters( 'mcp_adapter_discover_abilities_capability', 'read' ); 97 | // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is determined dynamically via filter 98 | if ( ! current_user_can( $required_capability ) ) { 99 | return new \WP_Error( 100 | 'insufficient_capability', 101 | sprintf( 'User lacks required capability: %s', $required_capability ) 102 | ); 103 | } 104 | 105 | return true; 106 | } 107 | 108 | /** 109 | * Execute the discover abilities functionality. 110 | * 111 | * Enforces security checks and mcp.public filtering. 112 | * 113 | * @param array $input Input parameters (unused for this ability). 114 | * 115 | * @return array Array containing public MCP abilities. 116 | */ 117 | public static function execute( $input = array() ): array { 118 | // Enforce security checks before execution 119 | $permission_check = self::check_permission( $input ); 120 | if ( is_wp_error( $permission_check ) ) { 121 | return array( 122 | 'error' => $permission_check->get_error_message(), 123 | ); 124 | } 125 | 126 | // Get all abilities and filter for publicly exposed ones 127 | $abilities = wp_get_abilities(); 128 | 129 | $ability_list = array(); 130 | foreach ( $abilities as $ability ) { 131 | $ability_name = $ability->get_name(); 132 | 133 | // Check if ability is publicly exposed via MCP 134 | if ( ! self::is_ability_mcp_public( $ability ) ) { 135 | continue; 136 | } 137 | 138 | // Only discover abilities with type='tool' (default type) 139 | if ( self::get_ability_mcp_type( $ability ) !== 'tool' ) { 140 | continue; 141 | } 142 | 143 | $ability_list[] = array( 144 | 'name' => $ability_name, 145 | 'label' => $ability->get_label(), 146 | 'description' => $ability->get_description(), 147 | ); 148 | } 149 | 150 | return array( 151 | 'abilities' => $ability_list, 152 | ); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /includes/Cli/McpCommand.php: -------------------------------------------------------------------------------- 1 | ] 33 | * : The ID of the MCP server to serve. If not specified, uses the first available server. 34 | * 35 | * [--user=] 36 | * : Run as a specific WordPress user for permission checks. 37 | * : Without this, runs as unauthenticated (limited capabilities). 38 | * 39 | * ## EXAMPLES 40 | * 41 | * # Serve the default MCP server as admin user 42 | * wp mcp serve --user=admin 43 | * 44 | * # Serve a specific server as user with ID 1 45 | * wp mcp serve --server=my-mcp-server --user=1 46 | * 47 | * # Serve without authentication (limited capabilities) 48 | * wp mcp serve --server=public-server 49 | * 50 | * @when after_wp_load 51 | * @synopsis [--server=] [--user=] 52 | */ 53 | public function serve( array $args, array $assoc_args ): void { 54 | 55 | // Get the MCP adapter instance 56 | $adapter = McpAdapter::instance(); 57 | 58 | // Get all registered servers 59 | $servers = $adapter->get_servers(); 60 | 61 | if ( empty( $servers ) ) { 62 | \WP_CLI::error( 'No MCP servers are registered. Please register at least one server first.' ); 63 | } 64 | 65 | // Determine which server to use 66 | $server_id = $assoc_args['server'] ?? null; 67 | $server = null; 68 | 69 | if ( $server_id ) { 70 | $server = $adapter->get_server( $server_id ); 71 | if ( ! $server ) { 72 | \WP_CLI::error( sprintf( 'Server with ID "%s" not found.', $server_id ) ); 73 | } 74 | } else { 75 | // Use the first available server 76 | $server = array_values( $servers )[0]; 77 | $server_id = $server->get_server_id(); 78 | \WP_CLI::line( sprintf( 'Using server: %s', $server_id ) ); 79 | } 80 | 81 | // Set user context if specified 82 | if ( isset( $assoc_args['user'] ) ) { 83 | $user = $this->get_user( $assoc_args['user'] ); 84 | if ( ! $user ) { 85 | \WP_CLI::error( sprintf( 'User "%s" not found.', $assoc_args['user'] ) ); 86 | } 87 | 88 | wp_set_current_user( $user->ID ); 89 | \WP_CLI::debug( sprintf( 'Running as user: %s (ID: %d)', $user->user_login, $user->ID ) ); 90 | } else { 91 | \WP_CLI::debug( 'Running without authentication. Some capabilities may be limited.' ); 92 | } 93 | 94 | // Create and start STDIO server bridge 95 | try { 96 | \WP_CLI::debug( sprintf( 'Starting STDIO bridge for server: %s', $server_id ) ); 97 | 98 | // Create STDIO server bridge 99 | $stdio_bridge = new StdioServerBridge( $server ); 100 | 101 | // Start serving (this blocks until terminated) 102 | $stdio_bridge->serve(); 103 | } catch ( \RuntimeException $e ) { 104 | \WP_CLI::error( $e->getMessage() ); 105 | } catch ( \Throwable $e ) { 106 | \WP_CLI::error( 'Failed to start STDIO bridge: ' . $e->getMessage() ); 107 | } 108 | } 109 | 110 | /** 111 | * List all registered MCP servers. 112 | * 113 | * ## OPTIONS 114 | * 115 | * [--format=] 116 | * : Render output in a particular format. 117 | * --- 118 | * default: table 119 | * options: 120 | * - table 121 | * - csv 122 | * - json 123 | * - yaml 124 | * --- 125 | * 126 | * ## EXAMPLES 127 | * 128 | * # List all MCP servers 129 | * wp mcp list 130 | * 131 | * # List servers in JSON format 132 | * wp mcp list --format=json 133 | * 134 | * @when after_wp_load 135 | * @synopsis [--format=] 136 | */ 137 | public function list( array $args, array $assoc_args ): void { 138 | $adapter = McpAdapter::instance(); 139 | 140 | $servers = $adapter->get_servers(); 141 | 142 | if ( empty( $servers ) ) { 143 | \WP_CLI::line( 'No MCP servers registered.' ); 144 | return; 145 | } 146 | 147 | $items = array(); 148 | foreach ( $servers as $server ) { 149 | $items[] = array( 150 | 'ID' => $server->get_server_id(), 151 | 'Name' => $server->get_server_name(), 152 | 'Version' => $server->get_server_version(), 153 | 'Tools' => count( $server->get_tools() ), 154 | 'Resources' => count( $server->get_resources() ), 155 | 'Prompts' => count( $server->get_prompts() ), 156 | 'Description' => $server->get_server_description(), 157 | ); 158 | } 159 | 160 | $format = $assoc_args['format'] ?? 'table'; 161 | format_items( $format, $items, array( 'ID', 'Name', 'Version', 'Tools', 'Resources', 'Prompts' ) ); 162 | } 163 | 164 | /** 165 | * Get a user by ID, login, or email. 166 | * 167 | * @param string $user User identifier (ID, login, or email). 168 | * @return \WP_User|false User object or false if not found. 169 | */ 170 | private function get_user( string $user ) { 171 | // Try as ID first 172 | if ( is_numeric( $user ) ) { 173 | return get_user_by( 'id', (int) $user ); 174 | } 175 | 176 | // Try as login 177 | $user_obj = get_user_by( 'login', $user ); 178 | if ( $user_obj ) { 179 | return $user_obj; 180 | } 181 | 182 | // Try as email 183 | return get_user_by( 'email', $user ); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /includes/Domain/Utils/McpAnnotationMapper.php: -------------------------------------------------------------------------------- 1 | , ability_property: string|null}> 33 | */ 34 | private static array $mcp_annotations = array( 35 | // Shared annotations (all features) - in annotations object. 36 | 'audience' => array( 37 | 'type' => 'array', 38 | 'features' => array( 'tool', 'resource', 'prompt' ), 39 | 'ability_property' => null, 40 | ), 41 | 'lastModified' => array( 42 | 'type' => 'string', 43 | 'features' => array( 'tool', 'resource', 'prompt' ), 44 | 'ability_property' => null, 45 | ), 46 | 'priority' => array( 47 | 'type' => 'number', 48 | 'features' => array( 'tool', 'resource', 'prompt' ), 49 | 'ability_property' => null, 50 | ), 51 | 'readOnlyHint' => array( 52 | 'type' => 'boolean', 53 | 'features' => array( 'tool' ), 54 | 'ability_property' => 'readonly', 55 | ), 56 | 'destructiveHint' => array( 57 | 'type' => 'boolean', 58 | 'features' => array( 'tool' ), 59 | 'ability_property' => 'destructive', 60 | ), 61 | 'idempotentHint' => array( 62 | 'type' => 'boolean', 63 | 'features' => array( 'tool' ), 64 | 'ability_property' => 'idempotent', 65 | ), 66 | 'openWorldHint' => array( 67 | 'type' => 'boolean', 68 | 'features' => array( 'tool' ), 69 | 'ability_property' => null, 70 | ), 71 | 'title' => array( 72 | 'type' => 'string', 73 | 'features' => array( 'tool' ), 74 | 'ability_property' => null, 75 | ), 76 | ); 77 | 78 | /** 79 | * Map WordPress ability annotation property names to MCP field names. 80 | * 81 | * Maps WordPress-format field names to MCP equivalents (e.g., readonly → readOnlyHint). 82 | * Only includes annotations applicable to the specified feature type. 83 | * Null values are excluded from the result. 84 | * 85 | * @param array $ability_annotations WordPress ability annotations. 86 | * @param string $feature_type The MCP feature type ('tool', 'resource', or 'prompt'). 87 | * 88 | * @return array Mapped annotations for the specified feature type. 89 | */ 90 | public static function map( array $ability_annotations, string $feature_type ): array { 91 | $result = array(); 92 | 93 | foreach ( self::$mcp_annotations as $mcp_field => $config ) { 94 | if ( ! in_array( $feature_type, $config['features'], true ) ) { 95 | continue; 96 | } 97 | 98 | $value = self::resolve_annotation_value( 99 | $ability_annotations, 100 | $mcp_field, 101 | $config['ability_property'] 102 | ); 103 | 104 | if ( null === $value ) { 105 | continue; 106 | } 107 | 108 | $normalized = self::normalize_annotation_value( $config['type'], $value ); 109 | if ( null === $normalized ) { 110 | continue; 111 | } 112 | 113 | $result[ $mcp_field ] = $normalized; 114 | } 115 | 116 | return $result; 117 | } 118 | 119 | /** 120 | * Resolve the annotation value, preferring WordPress-format overrides when available. 121 | * 122 | * @param array $annotations Raw annotations from the ability. 123 | * @param string $mcp_field The MCP field name. 124 | * @param string|null $ability_property Optional WordPress-format field name, or null if mapping 1:1. 125 | * 126 | * @return mixed The annotation value, or null if not found. 127 | */ 128 | private static function resolve_annotation_value( array $annotations, string $mcp_field, ?string $ability_property ) { 129 | // WordPress-format overrides take precedence when present. 130 | if ( null !== $ability_property && array_key_exists( $ability_property, $annotations ) && ! is_null( $annotations[ $ability_property ] ) ) { 131 | return $annotations[ $ability_property ]; 132 | } 133 | 134 | if ( array_key_exists( $mcp_field, $annotations ) && ! is_null( $annotations[ $mcp_field ] ) ) { 135 | return $annotations[ $mcp_field ]; 136 | } 137 | 138 | return null; 139 | } 140 | 141 | /** 142 | * Normalize annotation values to the types expected by MCP. 143 | * 144 | * @param string $field_type Expected MCP type (boolean, string, array, number). 145 | * @param mixed $value Raw annotation value. 146 | * 147 | * @return mixed|null Normalized value or null if invalid. 148 | */ 149 | private static function normalize_annotation_value( string $field_type, $value ) { 150 | switch ( $field_type ) { 151 | case 'boolean': 152 | return (bool) $value; 153 | 154 | case 'string': 155 | if ( ! is_scalar( $value ) ) { 156 | return null; 157 | } 158 | $trimmed = trim( (string) $value ); 159 | return '' === $trimmed ? null : $trimmed; 160 | 161 | case 'array': 162 | return is_array( $value ) && ! empty( $value ) ? $value : null; 163 | 164 | case 'number': 165 | return is_numeric( $value ) ? (float) $value : null; 166 | 167 | default: 168 | return $value; 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /includes/Domain/Resources/RegisterAbilityAsMcpResource.php: -------------------------------------------------------------------------------- 1 | 'WordPress://mcp-adapter/my-resource', 25 | * 'mimeType' => 'text/plain', 26 | * 'annotations' => array(...) 27 | * ) 28 | */ 29 | class RegisterAbilityAsMcpResource { 30 | /** 31 | * The WordPress ability instance. 32 | * 33 | * @var \WP_Ability 34 | */ 35 | private WP_Ability $ability; 36 | 37 | /** 38 | * The MCP server. 39 | * 40 | * @var \WP\MCP\Core\McpServer 41 | */ 42 | private McpServer $mcp_server; 43 | 44 | /** 45 | * Make a new instance of the class. 46 | * 47 | * @param \WP_Ability $ability The ability. 48 | * @param \WP\MCP\Core\McpServer $mcp_server The MCP server. 49 | * 50 | * @return \WP\MCP\Domain\Resources\McpResource|\WP_Error Returns resource instance or WP_Error if validation fails. 51 | */ 52 | public static function make( WP_Ability $ability, McpServer $mcp_server ) { 53 | $resource = new self( $ability, $mcp_server ); 54 | 55 | return $resource->get_resource(); 56 | } 57 | 58 | /** 59 | * Constructor. 60 | * 61 | * @param \WP_Ability $ability The ability. 62 | * @param \WP\MCP\Core\McpServer $mcp_server The MCP server. 63 | */ 64 | private function __construct( WP_Ability $ability, McpServer $mcp_server ) { 65 | $this->mcp_server = $mcp_server; 66 | $this->ability = $ability; 67 | } 68 | 69 | /** 70 | * Get the resource URI. 71 | * 72 | * @return string|\WP_Error URI string or WP_Error if not found in ability meta. 73 | */ 74 | public function get_uri() { 75 | $ability_meta = $this->ability->get_meta(); 76 | 77 | // First try to get URI from ability meta and normalize whitespace. 78 | if ( isset( $ability_meta['uri'] ) && is_string( $ability_meta['uri'] ) ) { 79 | $uri = trim( $ability_meta['uri'] ); 80 | if ( '' !== $uri ) { 81 | return $uri; 82 | } 83 | } 84 | 85 | // If not found in meta, return error since URI should be provided in ability meta 86 | return new \WP_Error( 87 | 'resource_uri_not_found', 88 | sprintf( 89 | "Resource URI not found in ability meta for '%s'. URI must be provided in ability meta data.", 90 | $this->ability->get_name() 91 | ) 92 | ); 93 | } 94 | 95 | /** 96 | * Get the MCP resource data array. 97 | * 98 | * @return array|\WP_Error Resource data array or WP_Error if URI is not found. 99 | */ 100 | private function get_data() { 101 | $uri = $this->get_uri(); 102 | if ( is_wp_error( $uri ) ) { 103 | return $uri; 104 | } 105 | 106 | $resource_data = array( 107 | 'ability' => $this->ability->get_name(), 108 | 'uri' => $uri, 109 | ); 110 | 111 | // Add optional name from ability label 112 | $label = trim( $this->ability->get_label() ); 113 | if ( ! empty( $label ) ) { 114 | $resource_data['name'] = $label; 115 | } 116 | 117 | // Add optional description 118 | $description = trim( $this->ability->get_description() ); 119 | if ( ! empty( $description ) ) { 120 | $resource_data['description'] = $description; 121 | } 122 | 123 | // Get resource content from ability 124 | $content = $this->get_ability_content(); 125 | if ( isset( $content['text'] ) ) { 126 | $resource_data['text'] = $content['text']; 127 | } 128 | if ( isset( $content['blob'] ) ) { 129 | $resource_data['blob'] = $content['blob']; 130 | } 131 | if ( isset( $content['mimeType'] ) ) { 132 | $resource_data['mimeType'] = $content['mimeType']; 133 | } 134 | 135 | // Map annotations from ability meta to MCP format using unified mapper. 136 | $ability_meta = $this->ability->get_meta(); 137 | if ( ! empty( $ability_meta['annotations'] ) && is_array( $ability_meta['annotations'] ) ) { 138 | $mcp_annotations = McpAnnotationMapper::map( $ability_meta['annotations'], 'resource' ); 139 | if ( ! empty( $mcp_annotations ) ) { 140 | $resource_data['annotations'] = $mcp_annotations; 141 | } 142 | } 143 | 144 | return $resource_data; 145 | } 146 | 147 | /** 148 | * Get resource content from the ability. 149 | * This method should be implemented based on how abilities provide resource content. 150 | * 151 | * @return array Array with 'text', 'blob', and/or 'mimeType' keys 152 | */ 153 | private function get_ability_content(): array { 154 | // @todo: Probably this can be improved so it will not be loaded when the resource list is called 155 | $content = array(); 156 | 157 | // Check if ability has resource content methods 158 | if ( method_exists( $this->ability, 'get_resource_content' ) ) { 159 | $resource_content = call_user_func( array( $this->ability, 'get_resource_content' ) ); 160 | if ( is_array( $resource_content ) ) { 161 | return $resource_content; 162 | } 163 | } 164 | 165 | // Fallback: try to get content from ability description as text 166 | $description = $this->ability->get_description(); 167 | if ( ! empty( $description ) ) { 168 | $content['text'] = $description; 169 | $content['mimeType'] = 'text/plain'; 170 | } 171 | 172 | return $content; 173 | } 174 | 175 | /** 176 | * Get the MCP resource instance. 177 | * Uses the centralized McpResourceValidator for consistent validation. 178 | * 179 | * @return \WP\MCP\Domain\Resources\McpResource|\WP_Error Returns the MCP resource instance or WP_Error if validation fails. 180 | */ 181 | private function get_resource() { 182 | $data = $this->get_data(); 183 | if ( is_wp_error( $data ) ) { 184 | return $data; 185 | } 186 | 187 | return McpResource::from_array( $data, $this->mcp_server ); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /includes/Domain/Prompts/RegisterAbilityAsMcpPrompt.php: -------------------------------------------------------------------------------- 1 | 'Code Review Prompt', 28 | * 'description' => 'Generate code review prompt', 29 | * 'input_schema' => array( 30 | * 'type' => 'object', 31 | * 'properties' => array( 32 | * 'code' => array('type' => 'string', 'description' => 'Code to review'), 33 | * ), 34 | * 'required' => array('code'), 35 | * ), 36 | * 'meta' => array( 37 | * 'mcp' => array('public' => true, 'type' => 'prompt'), 38 | * 'annotations' => array(...) 39 | * ) 40 | * ) 41 | * ); 42 | */ 43 | class RegisterAbilityAsMcpPrompt { 44 | /** 45 | * The WordPress ability instance. 46 | * 47 | * @var \WP_Ability 48 | */ 49 | private WP_Ability $ability; 50 | 51 | /** 52 | * The MCP server. 53 | * 54 | * @var \WP\MCP\Core\McpServer 55 | */ 56 | private McpServer $mcp_server; 57 | 58 | /** 59 | * Make a new instance of the class. 60 | * 61 | * @param \WP_Ability $ability The ability. 62 | * @param \WP\MCP\Core\McpServer $mcp_server The MCP server. 63 | * 64 | * @return \WP\MCP\Domain\Prompts\McpPrompt|\WP_Error Returns prompt instance or WP_Error if validation fails. 65 | */ 66 | public static function make( WP_Ability $ability, McpServer $mcp_server ) { 67 | $prompt = new self( $ability, $mcp_server ); 68 | 69 | return $prompt->get_prompt(); 70 | } 71 | 72 | /** 73 | * Constructor. 74 | * 75 | * @param \WP_Ability $ability The ability. 76 | * @param \WP\MCP\Core\McpServer $mcp_server The MCP server. 77 | */ 78 | private function __construct( WP_Ability $ability, McpServer $mcp_server ) { 79 | $this->mcp_server = $mcp_server; 80 | $this->ability = $ability; 81 | } 82 | 83 | /** 84 | * Get the MCP prompt data array. 85 | * 86 | * @return array 87 | */ 88 | private function get_data(): array { 89 | $ability_name = trim( $this->ability->get_name() ); 90 | $prompt_data = array( 91 | 'ability' => $ability_name, 92 | 'name' => str_replace( '/', '-', $ability_name ), 93 | ); 94 | 95 | // Add optional title from ability label 96 | $label = trim( $this->ability->get_label() ); 97 | if ( ! empty( $label ) ) { 98 | $prompt_data['title'] = $label; 99 | } 100 | 101 | // Add optional description 102 | $description = trim( $this->ability->get_description() ); 103 | if ( ! empty( $description ) ) { 104 | $prompt_data['description'] = $description; 105 | } 106 | 107 | $input_schema = $this->ability->get_input_schema(); 108 | if ( ! empty( $input_schema ) ) { 109 | $arguments = $this->convert_input_schema_to_arguments( $input_schema ); 110 | if ( ! empty( $arguments ) ) { 111 | $prompt_data['arguments'] = $arguments; 112 | } 113 | } 114 | 115 | // Map annotations from ability meta to MCP format using unified mapper. 116 | $ability_meta = $this->ability->get_meta(); 117 | if ( ! empty( $ability_meta['annotations'] ) && is_array( $ability_meta['annotations'] ) ) { 118 | $mcp_annotations = McpAnnotationMapper::map( $ability_meta['annotations'], 'prompt' ); 119 | if ( ! empty( $mcp_annotations ) ) { 120 | $prompt_data['annotations'] = $mcp_annotations; 121 | } 122 | } 123 | 124 | return $prompt_data; 125 | } 126 | 127 | /** 128 | * Convert JSON Schema input_schema to MCP prompt arguments format. 129 | * 130 | * Converts from WordPress Abilities JSON Schema format: 131 | * { 132 | * "type": "object", 133 | * "properties": { 134 | * "topic": {"type": "string", "description": "..."}, 135 | * "tone": {"type": "string", "description": "..."} 136 | * }, 137 | * "required": ["topic"] 138 | * } 139 | * 140 | * To MCP prompt arguments format: 141 | * [ 142 | * {"name": "topic", "description": "...", "required": true}, 143 | * {"name": "tone", "description": "...", "required": false} 144 | * ] 145 | * 146 | * @param array $input_schema The JSON Schema from ability. 147 | * @return array> MCP-formatted arguments array. 148 | */ 149 | private function convert_input_schema_to_arguments( array $input_schema ): array { 150 | $arguments = array(); 151 | 152 | // Ensure we have properties to convert 153 | if ( empty( $input_schema['properties'] ) || ! is_array( $input_schema['properties'] ) ) { 154 | return $arguments; 155 | } 156 | 157 | // Get the list of required properties 158 | $required_fields = array(); 159 | if ( isset( $input_schema['required'] ) && is_array( $input_schema['required'] ) ) { 160 | $required_fields = $input_schema['required']; 161 | } 162 | 163 | // Convert each property to an MCP argument 164 | foreach ( $input_schema['properties'] as $property_name => $property_schema ) { 165 | if ( ! is_array( $property_schema ) ) { 166 | continue; 167 | } 168 | 169 | $argument = array( 170 | 'name' => $property_name, 171 | 'required' => in_array( $property_name, $required_fields, true ), 172 | ); 173 | 174 | // Add description if available 175 | if ( ! empty( $property_schema['description'] ) ) { 176 | $argument['description'] = $property_schema['description']; 177 | } 178 | 179 | $arguments[] = $argument; 180 | } 181 | 182 | return $arguments; 183 | } 184 | 185 | /** 186 | * Get the MCP prompt instance. 187 | * 188 | * @return \WP\MCP\Domain\Prompts\McpPrompt|\WP_Error MCP prompt instance or WP_Error if validation fails. 189 | */ 190 | private function get_prompt() { 191 | return McpPrompt::from_array( $this->get_data(), $this->mcp_server ); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /includes/Handlers/Resources/ResourcesHandler.php: -------------------------------------------------------------------------------- 1 | mcp = $mcp; 36 | } 37 | 38 | 39 | /** 40 | * Handles the resources/list request. 41 | * 42 | * @param int $request_id Optional. The request ID for JSON-RPC. Default 0. 43 | * 44 | * @return array Response with resources list and metadata. 45 | */ 46 | public function list_resources( int $request_id = 0 ): array { 47 | // Get the registered resources from the MCP instance and extract only the args. 48 | $resources = array(); 49 | foreach ( $this->mcp->get_resources() as $resource ) { 50 | $resources[] = $resource->to_array(); 51 | } 52 | 53 | return array( 54 | 'resources' => $resources, 55 | '_metadata' => array( 56 | 'component_type' => 'resources', 57 | 'resources_count' => count( $resources ), 58 | ), 59 | ); 60 | } 61 | 62 | /** 63 | * Handles the resources/read request. 64 | * 65 | * @param array $params Request parameters. 66 | * @param int $request_id Optional. The request ID for JSON-RPC. Default 0. 67 | * 68 | * @return array Response with resource contents or error. 69 | */ 70 | public function read_resource( array $params, int $request_id = 0 ): array { 71 | // Extract parameters using helper method. 72 | $request_params = $this->extract_params( $params ); 73 | 74 | if ( ! isset( $request_params['uri'] ) ) { 75 | return array( 76 | 'error' => McpErrorFactory::missing_parameter( $request_id, 'uri' )['error'], 77 | '_metadata' => array( 78 | 'component_type' => 'resource', 79 | 'failure_reason' => 'missing_parameter', 80 | ), 81 | ); 82 | } 83 | 84 | // Implement resource reading logic here. 85 | $uri = $request_params['uri']; 86 | $resource = $this->mcp->get_resource( $uri ); 87 | 88 | if ( ! $resource ) { 89 | return array( 90 | 'error' => McpErrorFactory::resource_not_found( $request_id, $uri )['error'], 91 | '_metadata' => array( 92 | 'component_type' => 'resource', 93 | 'resource_uri' => $uri, 94 | 'failure_reason' => 'not_found', 95 | ), 96 | ); 97 | } 98 | 99 | /** 100 | * Get the ability 101 | * 102 | * @var \WP_Ability|\WP_Error $ability 103 | */ 104 | $ability = $resource->get_ability(); 105 | 106 | // Check if getting the ability returned an error 107 | if ( is_wp_error( $ability ) ) { 108 | $this->mcp->error_handler->log( 109 | 'Failed to get ability for resource', 110 | array( 111 | 'resource_uri' => $uri, 112 | 'error_message' => $ability->get_error_message(), 113 | ) 114 | ); 115 | 116 | return array( 117 | 'error' => McpErrorFactory::internal_error( $request_id, $ability->get_error_message() )['error'], 118 | '_metadata' => array( 119 | 'component_type' => 'resource', 120 | 'resource_uri' => $uri, 121 | 'resource_name' => $resource->get_name(), 122 | 'failure_reason' => 'ability_retrieval_failed', 123 | 'error_code' => $ability->get_error_code(), 124 | ), 125 | ); 126 | } 127 | 128 | try { 129 | $has_permission = $ability->check_permissions(); 130 | if ( true !== $has_permission ) { 131 | // Extract detailed error message and code if WP_Error was returned 132 | $error_message = 'Access denied for resource: ' . $resource->get_name(); 133 | $failure_reason = 'permission_denied'; 134 | 135 | if ( is_wp_error( $has_permission ) ) { 136 | $error_message = $has_permission->get_error_message(); 137 | $failure_reason = $has_permission->get_error_code(); // Use WP_Error code as failure_reason 138 | } 139 | 140 | return array( 141 | 'error' => McpErrorFactory::permission_denied( $request_id, $error_message )['error'], 142 | '_metadata' => array( 143 | 'component_type' => 'resource', 144 | 'resource_uri' => $uri, 145 | 'resource_name' => $resource->get_name(), 146 | 'ability_name' => $ability->get_name(), 147 | 'failure_reason' => $failure_reason, 148 | ), 149 | ); 150 | } 151 | 152 | $contents = $ability->execute(); 153 | 154 | // Handle WP_Error objects that weren't converted by the ability. 155 | if ( is_wp_error( $contents ) ) { 156 | $this->mcp->error_handler->log( 157 | 'Ability returned WP_Error object', 158 | array( 159 | 'ability' => $ability->get_name(), 160 | 'error_code' => $contents->get_error_code(), 161 | 'error_message' => $contents->get_error_message(), 162 | ) 163 | ); 164 | 165 | return array( 166 | 'error' => McpErrorFactory::internal_error( $request_id, $contents->get_error_message() )['error'], 167 | '_metadata' => array( 168 | 'component_type' => 'resource', 169 | 'resource_uri' => $uri, 170 | 'resource_name' => $resource->get_name(), 171 | 'ability_name' => $ability->get_name(), 172 | 'failure_reason' => 'wp_error', 173 | 'error_code' => $contents->get_error_code(), 174 | ), 175 | ); 176 | } 177 | 178 | // Successful execution - return contents. 179 | return array( 180 | 'contents' => $contents, 181 | '_metadata' => array( 182 | 'component_type' => 'resource', 183 | 'resource_uri' => $uri, 184 | 'resource_name' => $resource->get_name(), 185 | 'ability_name' => $ability->get_name(), 186 | ), 187 | ); 188 | } catch ( \Throwable $exception ) { 189 | $this->mcp->error_handler->log( 190 | 'Error reading resource', 191 | array( 192 | 'uri' => $uri, 193 | 'exception' => $exception->getMessage(), 194 | ) 195 | ); 196 | 197 | return array( 198 | 'error' => McpErrorFactory::internal_error( $request_id, 'Failed to read resource' )['error'], 199 | '_metadata' => array( 200 | 'component_type' => 'resource', 201 | 'resource_uri' => $uri, 202 | 'failure_reason' => 'execution_failed', 203 | 'error_type' => get_class( $exception ), 204 | ), 205 | ); 206 | } 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /includes/Abilities/GetAbilityInfoAbility.php: -------------------------------------------------------------------------------- 1 | 'Get Ability Info', 36 | 'description' => 'Get detailed information about a specific WordPress ability including its input/output schema, description, and usage examples.', 37 | 'category' => 'mcp-adapter', 38 | 'input_schema' => array( 39 | 'type' => 'object', 40 | 'properties' => array( 41 | 'ability_name' => array( 42 | 'type' => 'string', 43 | 'description' => 'The full name of the ability to get information about', 44 | ), 45 | ), 46 | 'required' => array( 'ability_name' ), 47 | 'additionalProperties' => false, 48 | ), 49 | 'output_schema' => array( 50 | 'type' => 'object', 51 | 'properties' => array( 52 | 'name' => array( 'type' => 'string' ), 53 | 'label' => array( 'type' => 'string' ), 54 | 'description' => array( 'type' => 'string' ), 55 | 'input_schema' => array( 56 | 'type' => 'object', 57 | 'description' => 'JSON Schema for the ability input parameters', 58 | ), 59 | 'output_schema' => array( 60 | 'type' => 'object', 61 | 'description' => 'JSON Schema for the ability output structure', 62 | ), 63 | 'meta' => array( 64 | 'type' => 'object', 65 | 'description' => 'Additional metadata about the ability', 66 | ), 67 | ), 68 | 'required' => array( 'name', 'label', 'description', 'input_schema' ), 69 | ), 70 | 'permission_callback' => array( self::class, 'check_permission' ), 71 | 'execute_callback' => array( self::class, 'execute' ), 72 | 'meta' => array( 73 | 'annotations' => array( 74 | 'readonly' => true, 75 | 'destructive' => false, 76 | 'idempotent' => true, 77 | ), 78 | ), 79 | ) 80 | ); 81 | } 82 | 83 | /** 84 | * Check permissions for getting ability info. 85 | * 86 | * Validates user capabilities, caller identity, and MCP exposure restrictions. 87 | * 88 | * @param array $input Input parameters containing ability_name. 89 | * 90 | * @return bool|\WP_Error True if the user has permission to get ability info. 91 | * @phpstan-return bool|\WP_Error 92 | */ 93 | public static function check_permission( $input = array() ) { 94 | $ability_name = $input['ability_name'] ?? ''; 95 | 96 | if ( empty( $ability_name ) ) { 97 | return new \WP_Error( 'missing_ability_name', 'Ability name is required' ); 98 | } 99 | 100 | // Validate user authentication and capabilities 101 | $user_check = self::validate_user_access(); 102 | if ( is_wp_error( $user_check ) ) { 103 | return $user_check; 104 | } 105 | 106 | // Check MCP exposure restrictions 107 | return self::check_ability_mcp_exposure( $ability_name ); 108 | } 109 | 110 | /** 111 | * Validate user authentication and basic capabilities for get ability info. 112 | * 113 | * @return bool|\WP_Error True if valid, WP_Error if validation fails. 114 | */ 115 | private static function validate_user_access() { 116 | // Verify caller identity - ensure user is authenticated 117 | if ( ! is_user_logged_in() ) { 118 | return new \WP_Error( 'authentication_required', 'User must be authenticated to access this ability' ); 119 | } 120 | 121 | // Check basic capability requirement - allow customization via filter 122 | $required_capability = apply_filters( 'mcp_adapter_get_ability_info_capability', 'read' ); 123 | // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is determined dynamically via filter 124 | if ( ! current_user_can( $required_capability ) ) { 125 | return new \WP_Error( 126 | 'insufficient_capability', 127 | sprintf( 'User lacks required capability: %s', $required_capability ) 128 | ); 129 | } 130 | 131 | return true; 132 | } 133 | 134 | /** 135 | * Execute the get ability info functionality. 136 | * 137 | * Enforces security checks before returning ability information. 138 | * 139 | * @param array $input Input parameters containing ability_name. 140 | * 141 | * @return array Array containing detailed ability information. 142 | */ 143 | public static function execute( $input = array() ): array { 144 | $ability_name = $input['ability_name'] ?? ''; 145 | 146 | if ( empty( $ability_name ) ) { 147 | return array( 148 | 'error' => 'Ability name is required', 149 | ); 150 | } 151 | 152 | // Enforce security checks before execution 153 | $permission_check = self::check_permission( $input ); 154 | if ( is_wp_error( $permission_check ) ) { 155 | return array( 156 | 'error' => $permission_check->get_error_message(), 157 | ); 158 | } 159 | 160 | $ability = wp_get_ability( $ability_name ); 161 | 162 | if ( ! $ability ) { 163 | return array( 164 | 'error' => "Ability '{$ability_name}' not found", 165 | ); 166 | } 167 | 168 | $ability_info = array( 169 | 'name' => $ability->get_name(), 170 | 'label' => $ability->get_label(), 171 | 'description' => $ability->get_description(), 172 | 'input_schema' => $ability->get_input_schema(), 173 | ); 174 | 175 | // Add output schema if available 176 | $output_schema = $ability->get_output_schema(); 177 | if ( ! empty( $output_schema ) ) { 178 | $ability_info['output_schema'] = $output_schema; 179 | } 180 | 181 | // Add meta information if available 182 | $meta = $ability->get_meta(); 183 | if ( ! empty( $meta ) ) { 184 | $ability_info['meta'] = $meta; 185 | } 186 | 187 | return $ability_info; 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /includes/Domain/Resources/McpResourceValidator.php: -------------------------------------------------------------------------------- 1 | to_array(), $context ); 63 | } 64 | 65 | /** 66 | * Get validation error details for debugging purposes. 67 | * This is the core validation method - all other validation methods use this. 68 | * 69 | * @param array $resource_data The resource data to validate. 70 | * 71 | * @return array Array of validation errors, empty if valid. 72 | */ 73 | public static function get_validation_errors( array $resource_data ): array { 74 | $errors = array(); 75 | 76 | // Sanitize string inputs. 77 | if ( isset( $resource_data['uri'] ) && is_string( $resource_data['uri'] ) ) { 78 | $resource_data['uri'] = trim( $resource_data['uri'] ); 79 | } 80 | if ( isset( $resource_data['name'] ) && is_string( $resource_data['name'] ) ) { 81 | $resource_data['name'] = trim( $resource_data['name'] ); 82 | } 83 | if ( isset( $resource_data['description'] ) && is_string( $resource_data['description'] ) ) { 84 | $resource_data['description'] = trim( $resource_data['description'] ); 85 | } 86 | if ( isset( $resource_data['mimeType'] ) && is_string( $resource_data['mimeType'] ) ) { 87 | $resource_data['mimeType'] = trim( $resource_data['mimeType'] ); 88 | } 89 | 90 | // Validate the required URI field. 91 | if ( empty( $resource_data['uri'] ) || ! is_string( $resource_data['uri'] ) ) { 92 | $errors[] = __( 'Resource URI is required and must be a non-empty string', 'mcp-adapter' ); 93 | } elseif ( ! McpValidator::validate_resource_uri( $resource_data['uri'] ) ) { 94 | $errors[] = __( 'Resource URI must be a valid URI format', 'mcp-adapter' ); 95 | } 96 | 97 | // Validate content - must have either text OR blob (but not both). 98 | $has_text = ! empty( $resource_data['text'] ); 99 | $has_blob = ! empty( $resource_data['blob'] ); 100 | 101 | if ( ! $has_text && ! $has_blob ) { 102 | $errors[] = __( 'Resource must have either text or blob content', 'mcp-adapter' ); 103 | } elseif ( $has_text && $has_blob ) { 104 | $errors[] = __( 'Resource cannot have both text and blob content - only one is allowed', 'mcp-adapter' ); 105 | } 106 | 107 | // Validate text content if present. 108 | if ( $has_text && ! is_string( $resource_data['text'] ) ) { 109 | $errors[] = __( 'Resource text content must be a string', 'mcp-adapter' ); 110 | } 111 | 112 | // Validate blob content if present. 113 | if ( $has_blob && ! is_string( $resource_data['blob'] ) ) { 114 | $errors[] = __( 'Resource blob content must be a string (base64-encoded)', 'mcp-adapter' ); 115 | } 116 | 117 | // Validate optional fields if present. 118 | if ( isset( $resource_data['name'] ) && ! is_string( $resource_data['name'] ) ) { 119 | $errors[] = __( 'Resource name must be a string if provided', 'mcp-adapter' ); 120 | } 121 | 122 | if ( isset( $resource_data['description'] ) && ! is_string( $resource_data['description'] ) ) { 123 | $errors[] = __( 'Resource description must be a string if provided', 'mcp-adapter' ); 124 | } 125 | 126 | if ( isset( $resource_data['mimeType'] ) ) { 127 | if ( ! is_string( $resource_data['mimeType'] ) ) { 128 | $errors[] = __( 'Resource mimeType must be a string if provided', 'mcp-adapter' ); 129 | } elseif ( ! McpValidator::validate_mime_type( $resource_data['mimeType'] ) ) { 130 | $errors[] = __( 'Resource mimeType must be a valid MIME type format', 'mcp-adapter' ); 131 | } 132 | } 133 | 134 | // Validate annotations structure if present. 135 | if ( isset( $resource_data['annotations'] ) ) { 136 | if ( ! is_array( $resource_data['annotations'] ) ) { 137 | $errors[] = __( 'Resource annotations must be an array if provided', 'mcp-adapter' ); 138 | } else { 139 | $annotation_errors = McpValidator::get_annotation_validation_errors( $resource_data['annotations'] ); 140 | if ( ! empty( $annotation_errors ) ) { 141 | $errors = array_merge( $errors, $annotation_errors ); 142 | } 143 | } 144 | } 145 | 146 | return $errors; 147 | } 148 | 149 | /** 150 | * Validate that the resource is unique within the MCP server. 151 | * 152 | * @param \WP\MCP\Domain\Resources\McpResource $the_resource The resource instance to validate. 153 | * @param string $context Optional context for error messages. 154 | * 155 | * @return bool|\WP_Error True if unique, WP_Error if the resource URI is not unique. 156 | */ 157 | public static function validate_resource_uniqueness( McpResource $the_resource, string $context = '' ) { 158 | $this_resource_uri = $the_resource->get_uri(); 159 | $existing_resource = $the_resource->get_mcp_server()->get_resource( $this_resource_uri ); 160 | if ( $existing_resource ) { 161 | $error_message = $context ? "[{$context}] " : ''; 162 | $error_message .= sprintf( 163 | /* translators: %s: resource URI */ 164 | __( 'Resource URI \'%s\' is not unique. It already exists in the MCP server.', 'mcp-adapter' ), 165 | $this_resource_uri 166 | ); 167 | return new \WP_Error( 'resource_not_unique', esc_html( $error_message ) ); 168 | } 169 | 170 | return true; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /includes/Domain/Prompts/McpPromptBuilder.php: -------------------------------------------------------------------------------- 1 | configure(); 65 | 66 | // Create a synthetic ability name for the prompt 67 | // Use empty string if name is empty (validation will catch it) 68 | $synthetic_ability = empty( $this->name ) ? 'synthetic/' : 'synthetic/' . $this->name; 69 | 70 | // Create a builder-based prompt that completely bypasses abilities 71 | $builder = $this; 72 | $prompt = new class( 73 | $synthetic_ability, 74 | $this->name, 75 | $this->title, 76 | $this->description, 77 | $this->arguments, 78 | $this->annotations, 79 | $builder 80 | ) extends McpPrompt { 81 | private McpPromptBuilderInterface $builder; 82 | 83 | public function __construct( 84 | string $ability, 85 | string $name, 86 | ?string $title, 87 | ?string $description, 88 | array $arguments, 89 | array $annotations, 90 | McpPromptBuilderInterface $builder 91 | ) { 92 | parent::__construct( $ability, $name, $title, $description, $arguments, $annotations ); 93 | $this->builder = $builder; 94 | } 95 | 96 | // This prompt is builder-based and doesn't need abilities 97 | public function is_builder_based(): bool { 98 | return true; 99 | } 100 | 101 | // Direct execution through the builder 102 | public function execute_direct( array $arguments ): array { 103 | return $this->builder->handle( $arguments ); 104 | } 105 | 106 | // Direct permission checking through the builder 107 | public function check_permission_direct( array $arguments ): bool { 108 | return $this->builder->has_permission( $arguments ); 109 | } 110 | 111 | /** 112 | * Fallback for ability-based execution (should not be used). 113 | * 114 | * @return \WP_Error Always returns an error as builder-based prompts don't have abilities. 115 | */ 116 | public function get_ability(): \WP_Error { 117 | // This should not be called for builder-based prompts 118 | return new \WP_Error( 119 | 'builder_has_no_ability', 120 | esc_html__( 'Builder-based prompts do not have an associated ability.', 'mcp-adapter' ) 121 | ); 122 | } 123 | }; 124 | 125 | return $prompt; 126 | } 127 | 128 | /** 129 | * Get the unique name for this prompt. 130 | * 131 | * @return string The prompt name. 132 | */ 133 | public function get_name(): string { 134 | if ( empty( $this->name ) ) { 135 | $this->configure(); 136 | } 137 | 138 | return $this->name; 139 | } 140 | 141 | /** 142 | * Get the prompt title. 143 | * 144 | * @return string|null The prompt title. 145 | */ 146 | public function get_title(): ?string { 147 | if ( empty( $this->name ) ) { 148 | $this->configure(); 149 | } 150 | 151 | return $this->title; 152 | } 153 | 154 | /** 155 | * Get the prompt description. 156 | * 157 | * @return string|null The prompt description. 158 | */ 159 | public function get_description(): ?string { 160 | if ( empty( $this->name ) ) { 161 | $this->configure(); 162 | } 163 | 164 | return $this->description; 165 | } 166 | 167 | /** 168 | * Get the prompt arguments. 169 | * 170 | * @return array The prompt arguments. 171 | */ 172 | public function get_arguments(): array { 173 | if ( empty( $this->name ) ) { 174 | $this->configure(); 175 | } 176 | 177 | return $this->arguments; 178 | } 179 | 180 | /** 181 | * Get the prompt annotations. 182 | * 183 | * @return array The prompt annotations. 184 | */ 185 | public function get_annotations(): array { 186 | if ( empty( $this->name ) ) { 187 | $this->configure(); 188 | } 189 | 190 | return $this->annotations; 191 | } 192 | 193 | /** 194 | * Configure the prompt properties. 195 | * 196 | * Subclasses must implement this method to set the name, title, 197 | * description, and arguments for the prompt. 198 | * 199 | * @return void 200 | */ 201 | abstract protected function configure(): void; 202 | 203 | /** 204 | * Handle the prompt execution when called. 205 | * 206 | * Subclasses must implement this method to handle the prompt logic. 207 | * 208 | * @param array $arguments The arguments passed to the prompt. 209 | * 210 | * @return array The prompt response. 211 | */ 212 | abstract public function handle( array $arguments ): array; 213 | 214 | /** 215 | * Check if the current user has permission to execute this prompt. 216 | * 217 | * Default implementation allows all executions. Override this method 218 | * to implement custom permission logic. 219 | * 220 | * @param array $arguments The arguments passed to the prompt. 221 | * 222 | * @return bool True if execution is allowed, false otherwise. 223 | */ 224 | public function has_permission( array $arguments ): bool { 225 | // Default: allow all executions 226 | // Override this method to implement custom permission logic 227 | return true; 228 | } 229 | 230 | /** 231 | * Helper method to create an argument definition. 232 | * 233 | * @param string $name The argument name. 234 | * @param string|null $description Optional argument description. 235 | * @param bool $required Whether the argument is required. 236 | * 237 | * @return array The argument definition. 238 | */ 239 | protected function create_argument( string $name, ?string $description = null, bool $required = false ): array { 240 | $argument = array( 241 | 'name' => $name, 242 | ); 243 | 244 | if ( ! is_null( $description ) ) { 245 | $argument['description'] = $description; 246 | } 247 | 248 | if ( $required ) { 249 | $argument['required'] = true; 250 | } 251 | 252 | return $argument; 253 | } 254 | 255 | /** 256 | * Helper method to add an argument to the prompt. 257 | * 258 | * @param string $name The argument name. 259 | * @param string|null $description Optional argument description. 260 | * @param bool $required Whether the argument is required. 261 | * 262 | * @return self 263 | */ 264 | protected function add_argument( string $name, ?string $description = null, bool $required = false ): self { 265 | $this->arguments[] = $this->create_argument( $name, $description, $required ); 266 | 267 | return $this; 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /includes/Abilities/ExecuteAbilityAbility.php: -------------------------------------------------------------------------------- 1 | 'Execute Ability', 37 | 'description' => 'Execute a WordPress ability with the provided parameters. This is the primary execution layer that can run any registered ability.', 38 | 'category' => 'mcp-adapter', 39 | 'input_schema' => array( 40 | 'type' => 'object', 41 | 'properties' => array( 42 | 'ability_name' => array( 43 | 'type' => 'string', 44 | 'description' => 'The full name of the ability to execute', 45 | ), 46 | 'parameters' => array( 47 | 'type' => 'object', 48 | 'description' => 'Parameters to pass to the ability', 49 | ), 50 | ), 51 | 'required' => array( 'ability_name', 'parameters' ), 52 | 'additionalProperties' => false, 53 | ), 54 | 'output_schema' => array( 55 | 'type' => 'object', 56 | 'properties' => array( 57 | 'success' => array( 'type' => 'boolean' ), 58 | 'data' => array( 59 | 'type' => array( 'object', 'array', 'string', 'number', 'integer', 'boolean', 'null' ), 60 | 'description' => 'The result data from the ability execution', 61 | ), 62 | 'error' => array( 63 | 'type' => 'string', 64 | 'description' => 'Error message if execution failed', 65 | ), 66 | ), 67 | 'required' => array( 'success' ), 68 | ), 69 | 'permission_callback' => array( self::class, 'check_permission' ), 70 | 'execute_callback' => array( self::class, 'execute' ), 71 | 'meta' => array( 72 | 'annotations' => array( 73 | 'readonly' => false, 74 | 'destructive' => true, 75 | 'idempotent' => false, 76 | ), 77 | ), 78 | ) 79 | ); 80 | } 81 | 82 | /** 83 | * Check permissions for executing abilities. 84 | * 85 | * Validates user capabilities, caller identity, and MCP exposure restrictions. 86 | * 87 | * @param array $input Input parameters containing ability_name and parameters. 88 | * 89 | * @return bool|\WP_Error True if the user has permission to execute the specified ability. 90 | * @phpstan-return bool|\WP_Error 91 | */ 92 | public static function check_permission( $input = array() ) { 93 | $ability_name = $input['ability_name'] ?? ''; 94 | 95 | if ( empty( $ability_name ) ) { 96 | return new \WP_Error( 'missing_ability_name', 'Ability name is required' ); 97 | } 98 | 99 | // Validate user authentication and capabilities 100 | $user_check = self::validate_user_access(); 101 | if ( is_wp_error( $user_check ) ) { 102 | return $user_check; 103 | } 104 | 105 | // Check MCP exposure restrictions 106 | $exposure_check = self::check_ability_mcp_exposure( $ability_name ); 107 | if ( is_wp_error( $exposure_check ) ) { 108 | return $exposure_check; 109 | } 110 | 111 | // Get the target ability 112 | $ability = wp_get_ability( $ability_name ); 113 | if ( ! $ability ) { 114 | return new \WP_Error( 'ability_not_found', "Ability '{$ability_name}' not found" ); 115 | } 116 | 117 | // Check if the user has permission to execute the target ability 118 | $parameters = empty( $input['parameters'] ) ? null : $input['parameters']; 119 | $permission_result = $ability->check_permissions( $parameters ); 120 | 121 | // Return WP_Error as-is, or convert other values to boolean 122 | if ( is_wp_error( $permission_result ) ) { 123 | return $permission_result; 124 | } 125 | 126 | return (bool) $permission_result; 127 | } 128 | 129 | /** 130 | * Validate user authentication and basic capabilities for execute ability. 131 | * 132 | * @return bool|\WP_Error True if valid, WP_Error if validation fails. 133 | */ 134 | private static function validate_user_access() { 135 | // Verify caller identity - ensure user is authenticated 136 | if ( ! is_user_logged_in() ) { 137 | return new \WP_Error( 'authentication_required', 'User must be authenticated to access this ability' ); 138 | } 139 | 140 | // Check basic capability requirement - allow customization via filter 141 | $required_capability = apply_filters( 'mcp_adapter_execute_ability_capability', 'read' ); 142 | // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is determined dynamically via filter 143 | if ( ! current_user_can( $required_capability ) ) { 144 | return new \WP_Error( 145 | 'insufficient_capability', 146 | sprintf( 'User lacks required capability: %s', $required_capability ) 147 | ); 148 | } 149 | 150 | return true; 151 | } 152 | 153 | /** 154 | * Execute the ability execution functionality. 155 | * 156 | * Enforces security checks before executing any ability. 157 | * 158 | * @param array $input Input parameters containing ability_name and parameters. 159 | * 160 | * @return array Array containing execution results. 161 | */ 162 | public static function execute( $input = array() ): array { 163 | $ability_name = $input['ability_name'] ?? ''; 164 | $parameters = empty( $input['parameters'] ) ? null : $input['parameters']; 165 | 166 | if ( empty( $ability_name ) ) { 167 | return array( 168 | 'success' => false, 169 | 'error' => 'Ability name is required', 170 | ); 171 | } 172 | 173 | // Enforce security checks before execution 174 | // Note: WordPress will have already called check_permission, but we double-check 175 | // as an additional security layer for direct method calls 176 | $permission_check = self::check_permission( $input ); 177 | if ( is_wp_error( $permission_check ) ) { 178 | return array( 179 | 'success' => false, 180 | 'error' => $permission_check->get_error_message(), 181 | ); 182 | } 183 | 184 | if ( ! $permission_check ) { 185 | return array( 186 | 'success' => false, 187 | 'error' => 'Permission denied for ability execution', 188 | ); 189 | } 190 | 191 | $ability = wp_get_ability( $ability_name ); 192 | 193 | if ( ! $ability ) { 194 | return array( 195 | 'success' => false, 196 | 'error' => "Ability '{$ability_name}' not found", 197 | ); 198 | } 199 | 200 | try { 201 | // Execute the ability 202 | $result = $ability->execute( $parameters ); 203 | 204 | // Check if the result is a WP_Error 205 | if ( is_wp_error( $result ) ) { 206 | return array( 207 | 'success' => false, 208 | 'error' => $result->get_error_message(), 209 | ); 210 | } 211 | 212 | return array( 213 | 'success' => true, 214 | 'data' => $result, 215 | ); 216 | } catch ( \Throwable $e ) { 217 | return array( 218 | 'success' => false, 219 | 'error' => $e->getMessage(), 220 | ); 221 | } 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /includes/Handlers/Prompts/PromptsHandler.php: -------------------------------------------------------------------------------- 1 | mcp = $mcp; 36 | } 37 | 38 | 39 | /** 40 | * Handles the prompts/list request. 41 | * 42 | * @param int $request_id Optional. The request ID for JSON-RPC. Default 0. 43 | * 44 | * @return array Response with prompts list and metadata. 45 | */ 46 | public function list_prompts( int $request_id = 0 ): array { 47 | // Get the registered prompts from the MCP instance and extract only the args. 48 | $prompts = array(); 49 | foreach ( $this->mcp->get_prompts() as $prompt ) { 50 | $prompts[] = $prompt->to_array(); 51 | } 52 | 53 | return array( 54 | 'prompts' => $prompts, 55 | '_metadata' => array( 56 | 'component_type' => 'prompts', 57 | 'prompts_count' => count( $prompts ), 58 | ), 59 | ); 60 | } 61 | 62 | /** 63 | * Handles the prompts/get request. 64 | * 65 | * @param array $params Request parameters. 66 | * @param int $request_id Optional. The request ID for JSON-RPC. Default 0. 67 | * 68 | * @return array Response with prompt execution results or error. 69 | */ 70 | public function get_prompt( array $params, int $request_id = 0 ): array { 71 | // Extract parameters using helper method. 72 | $request_params = $this->extract_params( $params ); 73 | 74 | if ( ! isset( $request_params['name'] ) ) { 75 | return array( 76 | 'error' => McpErrorFactory::missing_parameter( $request_id, 'name' )['error'], 77 | '_metadata' => array( 78 | 'component_type' => 'prompt', 79 | 'failure_reason' => 'missing_parameter', 80 | ), 81 | ); 82 | } 83 | 84 | // Get the prompt by name. 85 | $prompt_name = $request_params['name']; 86 | $prompt = $this->mcp->get_prompt( $prompt_name ); 87 | 88 | if ( ! $prompt ) { 89 | return array( 90 | 'error' => McpErrorFactory::prompt_not_found( $request_id, $prompt_name )['error'], 91 | '_metadata' => array( 92 | 'component_type' => 'prompt', 93 | 'prompt_name' => $prompt_name, 94 | 'failure_reason' => 'not_found', 95 | ), 96 | ); 97 | } 98 | 99 | // Get the arguments for the prompt. 100 | $arguments = $request_params['arguments'] ?? array(); 101 | 102 | try { 103 | // Check if this is a builder-based prompt that can execute directly 104 | if ( $prompt->is_builder_based() ) { 105 | // Direct execution through the builder (bypasses abilities completely) 106 | // Note: Builder permission checks return bool only, not WP_Error 107 | $has_permission = $prompt->check_permission_direct( $arguments ); 108 | if ( ! $has_permission ) { 109 | return array( 110 | 'error' => McpErrorFactory::permission_denied( $request_id, 'Access denied for prompt: ' . $prompt_name )['error'], 111 | '_metadata' => array( 112 | 'component_type' => 'prompt', 113 | 'prompt_name' => $prompt_name, 114 | 'failure_reason' => 'permission_denied', 115 | 'is_builder' => true, 116 | ), 117 | ); 118 | } 119 | 120 | $result = $prompt->execute_direct( $arguments ); 121 | $result['_metadata'] = array( 122 | 'component_type' => 'prompt', 123 | 'prompt_name' => $prompt_name, 124 | 'is_builder' => true, 125 | ); 126 | 127 | return $result; 128 | } 129 | 130 | /** 131 | * Traditional ability-based execution 132 | * 133 | * Get the ability for the prompt. 134 | * 135 | * @var \WP_Ability|\WP_Error $ability 136 | */ 137 | $ability = $prompt->get_ability(); 138 | 139 | // Check if getting the ability returned an error 140 | if ( is_wp_error( $ability ) ) { 141 | $this->mcp->error_handler->log( 142 | 'Failed to get ability for prompt', 143 | array( 144 | 'prompt_name' => $prompt_name, 145 | 'error_message' => $ability->get_error_message(), 146 | ) 147 | ); 148 | 149 | return array( 150 | 'error' => McpErrorFactory::internal_error( $request_id, $ability->get_error_message() )['error'], 151 | '_metadata' => array( 152 | 'component_type' => 'prompt', 153 | 'prompt_name' => $prompt_name, 154 | 'failure_reason' => 'ability_retrieval_failed', 155 | 'error_code' => $ability->get_error_code(), 156 | 'is_builder' => false, 157 | ), 158 | ); 159 | } 160 | 161 | // If ability has no input schema and arguments is empty, pass null 162 | // This is required by WP_Ability::validate_input() which expects null when no schema 163 | $ability_input_schema = $ability->get_input_schema(); 164 | if ( empty( $ability_input_schema ) && empty( $arguments ) ) { 165 | $arguments = null; 166 | } 167 | $has_permission = $ability->check_permissions( $arguments ); 168 | if ( true !== $has_permission ) { 169 | // Extract detailed error message and code if WP_Error was returned 170 | $error_message = 'Access denied for prompt: ' . $prompt_name; 171 | $failure_reason = 'permission_denied'; 172 | 173 | if ( is_wp_error( $has_permission ) ) { 174 | $error_message = $has_permission->get_error_message(); 175 | $failure_reason = $has_permission->get_error_code(); // Use WP_Error code as failure_reason 176 | } 177 | 178 | return array( 179 | 'error' => McpErrorFactory::permission_denied( $request_id, $error_message )['error'], 180 | '_metadata' => array( 181 | 'component_type' => 'prompt', 182 | 'prompt_name' => $prompt_name, 183 | 'ability_name' => $ability->get_name(), 184 | 'failure_reason' => $failure_reason, 185 | 'is_builder' => false, 186 | ), 187 | ); 188 | } 189 | 190 | $result = $ability->execute( $arguments ); 191 | 192 | // Handle WP_Error objects that weren't converted by the ability. 193 | if ( is_wp_error( $result ) ) { 194 | $this->mcp->error_handler->log( 195 | 'Ability returned WP_Error object', 196 | array( 197 | 'ability' => $ability->get_name(), 198 | 'error_code' => $result->get_error_code(), 199 | 'error_message' => $result->get_error_message(), 200 | ) 201 | ); 202 | 203 | return array( 204 | 'error' => McpErrorFactory::internal_error( $request_id, $result->get_error_message() )['error'], 205 | '_metadata' => array( 206 | 'component_type' => 'prompt', 207 | 'prompt_name' => $prompt_name, 208 | 'ability_name' => $ability->get_name(), 209 | 'failure_reason' => 'wp_error', 210 | 'error_code' => $result->get_error_code(), 211 | 'is_builder' => false, 212 | ), 213 | ); 214 | } 215 | 216 | // Successful execution - add metadata. 217 | $result['_metadata'] = array( 218 | 'component_type' => 'prompt', 219 | 'prompt_name' => $prompt_name, 220 | 'ability_name' => $ability->get_name(), 221 | 'is_builder' => false, 222 | ); 223 | 224 | return $result; 225 | } catch ( \Throwable $e ) { 226 | $this->mcp->error_handler->log( 227 | 'Prompt execution failed', 228 | array( 229 | 'prompt_name' => $prompt_name, 230 | 'arguments' => $arguments, 231 | 'error' => $e->getMessage(), 232 | ) 233 | ); 234 | 235 | return array( 236 | 'error' => McpErrorFactory::internal_error( $request_id, 'Prompt execution failed' )['error'], 237 | '_metadata' => array( 238 | 'component_type' => 'prompt', 239 | 'prompt_name' => $prompt_name, 240 | 'failure_reason' => 'execution_failed', 241 | 'error_type' => get_class( $e ), 242 | ), 243 | ); 244 | } 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /includes/Transport/Infrastructure/SessionManager.php: -------------------------------------------------------------------------------- 1 | Configuration array. 46 | */ 47 | private static function get_config(): array { 48 | return array( 49 | 'max_sessions' => (int) apply_filters( 'mcp_adapter_session_max_per_user', self::DEFAULT_MAX_SESSIONS ), 50 | 'inactivity_timeout' => (int) apply_filters( 'mcp_adapter_session_inactivity_timeout', self::DEFAULT_INACTIVITY_TIMEOUT ), 51 | ); 52 | } 53 | 54 | /** 55 | * Clear an inactive session (internal cleanup). 56 | * 57 | * @param int $user_id The user ID. 58 | * @param string $session_id The session ID to clear. 59 | * 60 | * @return void 61 | */ 62 | private static function clear_session( int $user_id, string $session_id ): void { 63 | $sessions = self::get_all_user_sessions( $user_id ); 64 | 65 | if ( ! isset( $sessions[ $session_id ] ) ) { 66 | return; 67 | } 68 | 69 | unset( $sessions[ $session_id ] ); 70 | update_user_meta( $user_id, self::SESSION_META_KEY, $sessions ); 71 | } 72 | 73 | /** 74 | * Create a new session for a user 75 | * 76 | * @param int $user_id The user ID. 77 | * @param array $params Client parameters from initialize request. 78 | * 79 | * @return string|false The session ID on success, false on failure. 80 | */ 81 | public static function create_session( int $user_id, array $params = array() ) { 82 | if ( ! $user_id || ! get_user_by( 'id', $user_id ) ) { 83 | return false; 84 | } 85 | 86 | // Cleanup inactive sessions first 87 | self::cleanup_expired_sessions( $user_id ); 88 | 89 | // Get current sessions 90 | $sessions = self::get_all_user_sessions( $user_id ); 91 | 92 | // Check session limit - remove oldest if over limit 93 | $config = self::get_config(); 94 | $max_sessions = $config['max_sessions']; 95 | if ( count( $sessions ) >= $max_sessions ) { 96 | // Remove oldest session (FIFO) - sort by created_at and remove first 97 | uasort( 98 | $sessions, 99 | static function ( $a, $b ) { 100 | return $a['created_at'] <=> $b['created_at']; 101 | } 102 | ); 103 | 104 | array_shift( $sessions ); 105 | } 106 | 107 | // Create a new session 108 | $session_id = wp_generate_uuid4(); 109 | $now = time(); 110 | 111 | $sessions[ $session_id ] = array( 112 | 'created_at' => $now, 113 | 'last_activity' => $now, 114 | 'client_params' => $params, 115 | ); 116 | 117 | // Save sessions 118 | update_user_meta( $user_id, self::SESSION_META_KEY, $sessions ); 119 | 120 | return $session_id; 121 | } 122 | 123 | /** 124 | * Get a specific session for a user 125 | * 126 | * @param int $user_id The user ID. 127 | * @param string $session_id The session ID. 128 | * 129 | * @return array|\WP_Error|false Session data on success, WP_Error on invalid input, false if not found or inactive. 130 | */ 131 | public static function get_session( int $user_id, string $session_id ) { 132 | if ( ! $user_id || ! $session_id ) { 133 | return new \WP_Error( 403, 'Invalid user ID or session ID.' ); 134 | } 135 | 136 | $sessions = self::get_all_user_sessions( $user_id ); 137 | 138 | if ( ! isset( $sessions[ $session_id ] ) ) { 139 | return false; 140 | } 141 | 142 | $session = $sessions[ $session_id ]; 143 | 144 | // Check inactivity timeout 145 | $config = self::get_config(); 146 | $inactivity_timeout = $config['inactivity_timeout']; 147 | if ( $session['last_activity'] + $inactivity_timeout < time() ) { 148 | self::clear_session( $user_id, $session_id ); 149 | 150 | return false; 151 | } 152 | 153 | return $session; 154 | } 155 | 156 | /** 157 | * Validate a session and update last activity 158 | * 159 | * @param int $user_id The user ID. 160 | * @param string $session_id The session ID. 161 | * 162 | * @return bool True if valid, false otherwise. 163 | */ 164 | public static function validate_session( int $user_id, string $session_id ): bool { 165 | if ( ! $user_id || ! $session_id ) { 166 | return false; 167 | } 168 | 169 | // Opportunistic cleanup 170 | self::cleanup_expired_sessions( $user_id ); 171 | 172 | $sessions = self::get_all_user_sessions( $user_id ); 173 | 174 | if ( ! isset( $sessions[ $session_id ] ) ) { 175 | return false; 176 | } 177 | 178 | $session = $sessions[ $session_id ]; 179 | 180 | // Check inactivity timeout 181 | $config = self::get_config(); 182 | $inactivity_timeout = $config['inactivity_timeout']; 183 | if ( $session['last_activity'] + $inactivity_timeout < time() ) { 184 | self::clear_session( $user_id, $session_id ); 185 | 186 | return false; 187 | } 188 | 189 | // Update last activity 190 | $sessions[ $session_id ]['last_activity'] = time(); 191 | update_user_meta( $user_id, self::SESSION_META_KEY, $sessions ); 192 | 193 | return true; 194 | } 195 | 196 | /** 197 | * Delete a specific session 198 | * 199 | * @param int $user_id The user ID. 200 | * @param string $session_id The session ID. 201 | * 202 | * @return bool True on success, false on failure. 203 | */ 204 | public static function delete_session( int $user_id, string $session_id ): bool { 205 | if ( ! $user_id || ! $session_id ) { 206 | return false; 207 | } 208 | 209 | $sessions = self::get_all_user_sessions( $user_id ); 210 | 211 | if ( ! isset( $sessions[ $session_id ] ) ) { 212 | return false; 213 | } 214 | 215 | unset( $sessions[ $session_id ] ); 216 | 217 | if ( empty( $sessions ) ) { 218 | delete_user_meta( $user_id, self::SESSION_META_KEY ); 219 | } else { 220 | update_user_meta( $user_id, self::SESSION_META_KEY, $sessions ); 221 | } 222 | 223 | return true; 224 | } 225 | 226 | /** 227 | * Cleanup inactive sessions for a user 228 | * 229 | * @param int $user_id The user ID. 230 | * 231 | * @return int Number of sessions removed. 232 | */ 233 | public static function cleanup_expired_sessions( int $user_id ): int { 234 | if ( ! $user_id ) { 235 | return 0; 236 | } 237 | 238 | $sessions = self::get_all_user_sessions( $user_id ); 239 | $now = time(); 240 | $removed = 0; 241 | 242 | $config = self::get_config(); 243 | $inactivity_timeout = $config['inactivity_timeout']; 244 | 245 | foreach ( $sessions as $session_id => $session ) { 246 | // Check if still active - skip if valid 247 | if ( $session['last_activity'] + $inactivity_timeout >= $now ) { 248 | continue; 249 | } 250 | 251 | // Session is inactive - remove it 252 | unset( $sessions[ $session_id ] ); 253 | ++$removed; 254 | } 255 | 256 | if ( $removed > 0 ) { 257 | if ( empty( $sessions ) ) { 258 | delete_user_meta( $user_id, self::SESSION_META_KEY ); 259 | } else { 260 | update_user_meta( $user_id, self::SESSION_META_KEY, $sessions ); 261 | } 262 | } 263 | 264 | return $removed; 265 | } 266 | 267 | /** 268 | * Get all sessions for a user 269 | * 270 | * @param int $user_id The user ID. 271 | * 272 | * @return array Array of sessions. 273 | */ 274 | public static function get_all_user_sessions( int $user_id ): array { 275 | if ( ! $user_id ) { 276 | return array(); 277 | } 278 | 279 | $sessions = get_user_meta( $user_id, self::SESSION_META_KEY, true ); 280 | 281 | if ( ! is_array( $sessions ) ) { 282 | return array(); 283 | } 284 | 285 | return $sessions; 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /includes/Transport/Infrastructure/HttpRequestHandler.php: -------------------------------------------------------------------------------- 1 | transport_context = $transport_context; 37 | } 38 | 39 | /** 40 | * Route HTTP request to appropriate handler. 41 | * 42 | * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext $context The HTTP request context. 43 | * 44 | * @return \WP_REST_Response HTTP response. 45 | */ 46 | public function handle_request( HttpRequestContext $context ): \WP_REST_Response { 47 | // Handle POST requests (sending MCP messages to server) 48 | if ( 'POST' === $context->method ) { 49 | return $this->handle_mcp_request( $context ); 50 | } 51 | 52 | // Handle GET requests (listening for messages from server via SSE) 53 | if ( 'GET' === $context->method ) { 54 | return $this->handle_sse_request( $context ); 55 | } 56 | 57 | // Handle DELETE requests (session termination) 58 | if ( 'DELETE' === $context->method ) { 59 | return $this->handle_session_termination( $context ); 60 | } 61 | 62 | // Method not allowed 63 | return new \WP_REST_Response( 64 | McpErrorFactory::internal_error( 0, 'Method not allowed' ), 65 | 405 66 | ); 67 | } 68 | 69 | 70 | /** 71 | * Handle MCP POST requests. 72 | * 73 | * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext $context The HTTP request context. 74 | * 75 | * @return \WP_REST_Response MCP response. 76 | */ 77 | private function handle_mcp_request( HttpRequestContext $context ): \WP_REST_Response { 78 | try { 79 | // Validate request body 80 | if ( null === $context->body ) { 81 | return new \WP_REST_Response( 82 | McpErrorFactory::parse_error( 0, 'Invalid JSON in request body' ), 83 | 400 84 | ); 85 | } 86 | 87 | return $this->process_mcp_messages( $context ); 88 | } catch ( \Throwable $exception ) { 89 | $this->transport_context->mcp_server->error_handler->log( 90 | 'Unexpected error in handle_mcp_request', 91 | array( 92 | 'transport' => static::class, 93 | 'server_id' => $this->transport_context->mcp_server->get_server_id(), 94 | 'error' => $exception->getMessage(), 95 | ) 96 | ); 97 | 98 | return new \WP_REST_Response( 99 | McpErrorFactory::internal_error( 0, 'Handler error occurred' ), 100 | 500 101 | ); 102 | } 103 | } 104 | 105 | /** 106 | * Process MCP messages using JsonRpcResponseBuilder. 107 | * 108 | * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext $context The HTTP request context. 109 | * 110 | * @return \WP_REST_Response MCP response. 111 | */ 112 | private function process_mcp_messages( HttpRequestContext $context ): \WP_REST_Response { 113 | $is_batch_request = JsonRpcResponseBuilder::is_batch_request( $context->body ); 114 | $messages = JsonRpcResponseBuilder::normalize_messages( $context->body ); 115 | 116 | $response_body = JsonRpcResponseBuilder::process_messages( 117 | $messages, 118 | $is_batch_request, 119 | function ( array $message ) use ( $context ) { 120 | return $this->process_single_message( $message, $context ); 121 | } 122 | ); 123 | 124 | // Determine HTTP status code based on error type 125 | if ( ! $is_batch_request && isset( $response_body['error'] ) ) { 126 | $http_status = McpErrorFactory::get_http_status_for_error( $response_body ); 127 | return new \WP_REST_Response( $response_body, $http_status ); 128 | } 129 | 130 | return new \WP_REST_Response( $response_body, 200 ); 131 | } 132 | 133 | /** 134 | * Process a single MCP message. 135 | * 136 | * @param array $message The MCP JSON-RPC message. 137 | * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext $context The HTTP request context. 138 | * 139 | * @return array|null JSON-RPC response or null for notifications. 140 | */ 141 | private function process_single_message( array $message, HttpRequestContext $context ): ?array { 142 | // Validate JSON-RPC message format 143 | $validation = McpErrorFactory::validate_jsonrpc_message( $message ); 144 | if ( isset( $validation['error'] ) ) { 145 | return $validation; 146 | } 147 | 148 | // Handle notifications (no response required) 149 | if ( isset( $message['method'] ) && ! isset( $message['id'] ) ) { 150 | return null; // Notifications don't get a response 151 | } 152 | 153 | // Process requests with IDs 154 | if ( isset( $message['method'] ) && isset( $message['id'] ) ) { 155 | return $this->process_jsonrpc_request( $message, $context ); 156 | } 157 | 158 | return null; 159 | } 160 | 161 | /** 162 | * Process a JSON-RPC request message. 163 | * 164 | * @param array $message The JSON-RPC message. 165 | * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext $context The HTTP request context. 166 | * 167 | * @return array JSON-RPC response. 168 | */ 169 | private function process_jsonrpc_request( array $message, HttpRequestContext $context ): array { 170 | $request_id = $message['id']; // Preserve original scalar ID (string, number, or null) 171 | $method = $message['method']; 172 | $params = $message['params'] ?? array(); 173 | 174 | // Validate session for all requests except initialize (router will handle initialize session creation) 175 | if ( 'initialize' !== $method ) { 176 | $session_validation = HttpSessionValidator::validate_session( $context ); 177 | if ( true !== $session_validation ) { 178 | return JsonRpcResponseBuilder::create_error_response( $request_id, $session_validation['error'] ?? $session_validation ); 179 | } 180 | } 181 | 182 | // Route the request through the transport context 183 | $result = $this->transport_context->request_router->route_request( 184 | $method, 185 | $params, 186 | $request_id, 187 | $this->get_transport_name(), 188 | $context 189 | ); 190 | 191 | // Handle session header if provided by router 192 | if ( isset( $result['_session_id'] ) ) { 193 | $this->add_session_header_to_response( $result['_session_id'] ); 194 | unset( $result['_session_id'] ); // Remove from actual response data 195 | } 196 | 197 | // Format response based on result 198 | if ( isset( $result['error'] ) ) { 199 | return JsonRpcResponseBuilder::create_error_response( $request_id, $result['error'] ); 200 | } 201 | 202 | return JsonRpcResponseBuilder::create_success_response( $request_id, $result ); 203 | } 204 | 205 | 206 | /** 207 | * Handle GET requests (SSE streaming). 208 | * 209 | * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext $context The HTTP request context. 210 | * 211 | * @return \WP_REST_Response SSE response. 212 | */ 213 | private function handle_sse_request( HttpRequestContext $context ): \WP_REST_Response { 214 | // SSE streaming not yet implemented - return HTTP 405 with no body 215 | return new \WP_REST_Response( null, 405 ); 216 | } 217 | 218 | /** 219 | * Handle DELETE requests (session termination). 220 | * 221 | * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext $context The HTTP request context. 222 | * 223 | * @return \WP_REST_Response Termination response. 224 | */ 225 | private function handle_session_termination( HttpRequestContext $context ): \WP_REST_Response { 226 | $result = HttpSessionValidator::terminate_session( $context ); 227 | 228 | if ( true !== $result ) { 229 | $http_status = McpErrorFactory::get_http_status_for_error( $result ); 230 | return new \WP_REST_Response( $result, $http_status ); 231 | } 232 | 233 | return new \WP_REST_Response( null, 200 ); 234 | } 235 | 236 | /** 237 | * Get transport name for observability. 238 | * 239 | * @return string Transport name. 240 | */ 241 | private function get_transport_name(): string { 242 | return 'HTTP'; 243 | } 244 | 245 | /** 246 | * Add session header to the REST response. 247 | * 248 | * Uses a static flag to prevent multiple filters from being added 249 | * if this method is called multiple times during a single request 250 | * (e.g., during batch JSON-RPC processing). 251 | * 252 | * @param string $session_id The session ID to add to the response header. 253 | * 254 | * @return void 255 | */ 256 | private function add_session_header_to_response( string $session_id ): void { 257 | static $current_session_id = null; 258 | 259 | // Only add filter once per request, or if session ID changes 260 | if ( null !== $current_session_id && $current_session_id === $session_id ) { 261 | return; 262 | } 263 | 264 | add_filter( 265 | 'rest_post_dispatch', 266 | static function ( $response ) use ( $session_id ) { 267 | if ( $response instanceof \WP_REST_Response ) { 268 | $response->header( 'Mcp-Session-Id', $session_id ); 269 | } 270 | 271 | return $response; 272 | } 273 | ); 274 | 275 | $current_session_id = $session_id; 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /includes/Transport/Infrastructure/RequestRouter.php: -------------------------------------------------------------------------------- 1 | context = $context; 38 | } 39 | 40 | /** 41 | * Route a request to the appropriate handler. 42 | * 43 | * @param string $method The MCP method name. 44 | * @param array $params The request parameters. 45 | * @param mixed $request_id The request ID (for JSON-RPC) - string, number, or null. 46 | * @param string $transport_name Transport name for observability. 47 | * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext|null $http_context HTTP context for session management. 48 | * 49 | * @return array 50 | */ 51 | public function route_request( string $method, array $params, $request_id = 0, string $transport_name = 'unknown', ?HttpRequestContext $http_context = null ): array { 52 | // Track request start time. 53 | $start_time = microtime( true ); 54 | 55 | // Common tags for all metrics. 56 | $common_tags = array( 57 | 'method' => $method, 58 | 'transport' => $transport_name, 59 | 'server_id' => $this->context->mcp_server->get_server_id(), 60 | 'params' => $this->sanitize_params_for_logging( $params ), 61 | 'request_id' => $request_id, 62 | 'session_id' => $http_context ? $http_context->session_id : null, 63 | ); 64 | 65 | $handlers = array( 66 | 'initialize' => fn() => $this->handle_initialize_with_session( $params, $request_id, $http_context ), 67 | 'ping' => fn() => $this->context->system_handler->ping( $request_id ), 68 | 'tools/list' => fn() => $this->context->tools_handler->list_tools( $request_id ), 69 | 'tools/list/all' => fn() => $this->context->tools_handler->list_all_tools( $request_id ), 70 | 'tools/call' => fn() => $this->context->tools_handler->call_tool( $params, $request_id ), 71 | 'resources/list' => fn() => $this->add_cursor_compatibility( $this->context->resources_handler->list_resources( $request_id ) ), 72 | 'resources/read' => fn() => $this->context->resources_handler->read_resource( $params, $request_id ), 73 | 'prompts/list' => fn() => $this->context->prompts_handler->list_prompts( $request_id ), 74 | 'prompts/get' => fn() => $this->context->prompts_handler->get_prompt( $params, $request_id ), 75 | 'logging/setLevel' => fn() => $this->context->system_handler->set_logging_level( $params, $request_id ), 76 | 'completion/complete' => fn() => $this->context->system_handler->complete( $request_id ), 77 | 'roots/list' => fn() => $this->context->system_handler->list_roots( $request_id ), 78 | ); 79 | 80 | try { 81 | $result = isset( $handlers[ $method ] ) ? $handlers[ $method ]() : $this->create_method_not_found_error( $method ); 82 | 83 | // Calculate request duration. 84 | $duration = ( microtime( true ) - $start_time ) * 1000; // Convert to milliseconds. 85 | 86 | // Extract metadata from handler response (if present). 87 | $metadata = $result['_metadata'] ?? array(); 88 | unset( $result['_metadata'] ); // Don't send to client. 89 | 90 | // Capture newly created session ID from initialize if present. 91 | if ( isset( $result['_session_id'] ) ) { 92 | $metadata['new_session_id'] = $result['_session_id']; 93 | } 94 | 95 | // Merge common tags with handler metadata. 96 | $tags = array_merge( $common_tags, $metadata ); 97 | 98 | // Determine status and record event. 99 | if ( isset( $result['error'] ) ) { 100 | $tags['status'] = 'error'; 101 | $tags['error_code'] = $result['error']['code'] ?? -32603; 102 | $this->context->observability_handler->record_event( 'mcp.request', $tags, $duration ); 103 | 104 | return $result; 105 | } 106 | 107 | // Successful request. 108 | $tags['status'] = 'success'; 109 | $this->context->observability_handler->record_event( 'mcp.request', $tags, $duration ); 110 | 111 | return $result; 112 | } catch ( \Throwable $exception ) { 113 | // Calculate request duration. 114 | $duration = ( microtime( true ) - $start_time ) * 1000; // Convert to milliseconds. 115 | 116 | // Track exception with categorization. 117 | $tags = array_merge( 118 | $common_tags, 119 | array( 120 | 'status' => 'error', 121 | 'error_type' => get_class( $exception ), 122 | 'error_category' => $this->categorize_error( $exception ), 123 | ) 124 | ); 125 | $this->context->observability_handler->record_event( 'mcp.request', $tags, $duration ); 126 | 127 | // Create error response from exception. 128 | return array( 'error' => McpErrorFactory::internal_error( $request_id, 'Handler error occurred' )['error'] ); 129 | } 130 | } 131 | 132 | /** 133 | * Add nextCursor for backward compatibility with existing API. 134 | * 135 | * @param array $result The result array. 136 | * @return array 137 | */ 138 | public function add_cursor_compatibility( array $result ): array { 139 | if ( ! isset( $result['nextCursor'] ) ) { 140 | $result['nextCursor'] = ''; 141 | } 142 | 143 | return $result; 144 | } 145 | 146 | /** 147 | * Handle initialize requests with session management. 148 | * 149 | * @param array $params The request parameters. 150 | * @param mixed $request_id The request ID. 151 | * @param \WP\MCP\Transport\Infrastructure\HttpRequestContext|null $http_context HTTP context for session management. 152 | * @return array 153 | */ 154 | private function handle_initialize_with_session( array $params, $request_id, ?HttpRequestContext $http_context ): array { 155 | // Get the initialize response from the handler 156 | $result = $this->context->initialize_handler->handle( $request_id ); 157 | 158 | // Handle session creation if HTTP context is provided and initialize was successful 159 | if ( $http_context && ! isset( $result['error'] ) && ! $http_context->session_id ) { 160 | $session_result = HttpSessionValidator::create_session( $params ); 161 | 162 | if ( is_array( $session_result ) ) { 163 | // Session creation failed - extract inner error from JSON-RPC response 164 | return array( 'error' => $session_result['error'] ?? $session_result ); 165 | } 166 | 167 | // Store session ID in result for HttpRequestHandler to add as header 168 | $result['_session_id'] = $session_result; 169 | } 170 | 171 | return $result; 172 | } 173 | 174 | /** 175 | * Create a method not found error with generic format. 176 | * 177 | * @param string $method The method that was not found. 178 | * @return array 179 | */ 180 | private function create_method_not_found_error( string $method ): array { 181 | return array( 182 | 'error' => McpErrorFactory::method_not_found( 0, $method )['error'], 183 | ); 184 | } 185 | 186 | /** 187 | * Categorize an exception into a general error category. 188 | * 189 | * @param \Throwable $exception The exception to categorize. 190 | * 191 | * @return string 192 | */ 193 | private function categorize_error( \Throwable $exception ): string { 194 | $error_categories = array( 195 | \ArgumentCountError::class => 'arguments', 196 | \Error::class => 'system', 197 | \InvalidArgumentException::class => 'validation', 198 | \LogicException::class => 'logic', 199 | \RuntimeException::class => 'execution', 200 | \TypeError::class => 'type', 201 | ); 202 | 203 | return $error_categories[ get_class( $exception ) ] ?? 'unknown'; 204 | } 205 | 206 | /** 207 | * Sanitize request params for logging to remove sensitive data and limit size. 208 | * 209 | * @param array $params The request parameters to sanitize. 210 | * 211 | * @return array Sanitized parameters safe for logging. 212 | */ 213 | private function sanitize_params_for_logging( array $params ): array { 214 | // Return early for empty parameters. 215 | if ( empty( $params ) ) { 216 | return array(); 217 | } 218 | 219 | $sanitized = array(); 220 | 221 | // Extract only safe, useful fields for observability 222 | $safe_fields = array( 'name', 'protocolVersion', 'uri' ); 223 | 224 | foreach ( $safe_fields as $field ) { 225 | if ( ! isset( $params[ $field ] ) || ! is_scalar( $params[ $field ] ) ) { 226 | continue; 227 | } 228 | 229 | $sanitized[ $field ] = $params[ $field ]; 230 | } 231 | 232 | // Add clientInfo name if available (useful for debugging) 233 | if ( isset( $params['clientInfo']['name'] ) ) { 234 | $sanitized['client_name'] = $params['clientInfo']['name']; 235 | } 236 | 237 | // Add arguments count for tool calls (but not the actual arguments to avoid logging sensitive data) 238 | if ( isset( $params['arguments'] ) && is_array( $params['arguments'] ) ) { 239 | $sanitized['arguments_count'] = count( $params['arguments'] ); 240 | $sanitized['arguments_keys'] = array_keys( $params['arguments'] ); 241 | } 242 | 243 | return $sanitized; 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /includes/Cli/StdioServerBridge.php: -------------------------------------------------------------------------------- 1 | server = $server; 55 | 56 | // Create request router using server's infrastructure 57 | $this->request_router = $this->create_request_router(); 58 | } 59 | 60 | /** 61 | * Start the STDIO server bridge. 62 | * 63 | * This method reads JSON-RPC messages from stdin and writes responses to stdout. 64 | * It runs in a loop until terminated or until it receives a shutdown signal. 65 | * 66 | * @throws \RuntimeException If STDIO transport is disabled. 67 | */ 68 | public function serve(): void { 69 | // Check if STDIO transport is enabled 70 | $enable_serve = apply_filters( 'mcp_adapter_enable_stdio_transport', true ); 71 | 72 | if ( ! $enable_serve ) { 73 | throw new \RuntimeException( 74 | 'The STDIO transport is disabled. Enable it by setting the "mcp_adapter_enable_stdio_transport" filter to true.' 75 | ); 76 | } 77 | 78 | $this->is_running = true; 79 | 80 | // Log to stderr to keep stdout clean for MCP messages 81 | $this->log_to_stderr( sprintf( 'MCP STDIO Bridge started for server: %s', $this->server->get_server_id() ) ); 82 | 83 | // Main server loop 84 | while ( $this->is_running ) { 85 | try { 86 | // Read a line from stdin (blocking) 87 | $input = fgets( STDIN ); 88 | 89 | if ( false === $input ) { 90 | // EOF or error reading from stdin 91 | break; 92 | } 93 | 94 | // Trim newline delimiter 95 | $input = rtrim( $input, "\r\n" ); 96 | 97 | if ( empty( $input ) ) { 98 | // Empty line, continue reading 99 | continue; 100 | } 101 | 102 | // Process the request and get response 103 | $response = $this->handle_request( $input ); 104 | 105 | // Write response to stdout with newline delimiter 106 | if ( ! empty( $response ) ) { 107 | // Use fwrite() for precise binary-safe JSON-RPC protocol communication. 108 | // WP_CLI output functions would add formatting/prefixes that break MCP protocol. 109 | // MCP requires exact control over stdout for machine-to-machine communication. 110 | fwrite( STDOUT, $response . "\n" ); // phpcs:ignore 111 | fflush( STDOUT ); 112 | } 113 | } catch ( \Throwable $e ) { 114 | // Log errors to stderr 115 | $this->log_to_stderr( 'Error processing request: ' . $e->getMessage() ); 116 | 117 | // Send error response 118 | $error_response = wp_json_encode( 119 | array( 120 | 'jsonrpc' => '2.0', 121 | 'error' => array( 122 | 'code' => -32603, 123 | 'message' => 'Internal error', 124 | 'data' => array( 125 | 'details' => $e->getMessage(), 126 | ), 127 | ), 128 | 'id' => null, 129 | ) 130 | ); 131 | 132 | fwrite( STDOUT, $error_response . "\n" ); // phpcs:ignore 133 | fflush( STDOUT ); 134 | } 135 | } 136 | 137 | $this->log_to_stderr( 'MCP STDIO Bridge stopped' ); 138 | } 139 | 140 | /** 141 | * Stop the STDIO server bridge. 142 | */ 143 | public function stop(): void { 144 | $this->is_running = false; 145 | } 146 | 147 | /** 148 | * Handle a JSON-RPC request string and return a JSON-RPC response string. 149 | * 150 | * @param string $json_input The JSON-RPC request string. 151 | * 152 | * @return string The JSON-RPC response string (empty for notifications). 153 | */ 154 | private function handle_request( string $json_input ): string { 155 | try { 156 | // Parse JSON-RPC request 157 | $request = json_decode( $json_input, true ); 158 | 159 | if ( json_last_error() !== JSON_ERROR_NONE ) { 160 | return $this->create_error_response( 161 | null, 162 | -32700, 163 | 'Parse error', 164 | 'Invalid JSON was received by the server.' 165 | ); 166 | } 167 | 168 | // Validate JSON-RPC structure 169 | if ( ! is_array( $request ) ) { 170 | return $this->create_error_response( 171 | null, 172 | -32600, 173 | 'Invalid Request', 174 | 'The JSON sent is not a valid Request object.' 175 | ); 176 | } 177 | 178 | // Check for JSON-RPC version 179 | if ( ! isset( $request['jsonrpc'] ) || '2.0' !== $request['jsonrpc'] ) { 180 | return $this->create_error_response( 181 | $request['id'] ?? null, 182 | -32600, 183 | 'Invalid Request', 184 | 'The JSON-RPC version must be 2.0.' 185 | ); 186 | } 187 | 188 | // Extract request components 189 | $method = $request['method'] ?? null; 190 | $params = $request['params'] ?? array(); 191 | $id = $request['id'] ?? null; 192 | 193 | if ( ! is_string( $method ) ) { 194 | return $this->create_error_response( 195 | $id, 196 | -32600, 197 | 'Invalid Request', 198 | 'Method must be a string.' 199 | ); 200 | } 201 | 202 | // Convert params to array if it's an object 203 | if ( is_object( $params ) ) { 204 | $params = (array) $params; 205 | } 206 | 207 | if ( ! is_array( $params ) ) { 208 | $params = array(); 209 | } 210 | 211 | // Route the request to the appropriate handler 212 | $result = $this->request_router->route_request( 213 | $method, 214 | $params, 215 | $id, 216 | 'stdio' 217 | ); 218 | 219 | // If this is a notification (no id), don't send a response 220 | if ( null === $id ) { 221 | return ''; 222 | } 223 | 224 | // Format the response 225 | return $this->format_response( $result, $id ); 226 | } catch ( \Throwable $e ) { 227 | // Handle unexpected errors 228 | return $this->create_error_response( 229 | null, 230 | -32603, 231 | 'Internal error', 232 | $e->getMessage() 233 | ); 234 | } 235 | } 236 | 237 | /** 238 | * Format a handler result as a JSON-RPC response. 239 | * 240 | * @param array $result The handler result. 241 | * @param mixed $id The request ID. 242 | * 243 | * @return string The JSON-RPC response string. 244 | */ 245 | private function format_response( array $result, $id ): string { 246 | $response = array( 247 | 'jsonrpc' => '2.0', 248 | 'id' => $id, 249 | ); 250 | 251 | // Check if result contains an error 252 | if ( isset( $result['error'] ) ) { 253 | $error = $result['error']; 254 | 255 | // Ensure error has required fields 256 | $response['error'] = array( 257 | 'code' => $error['code'] ?? -32603, 258 | 'message' => $error['message'] ?? 'Internal error', 259 | ); 260 | 261 | // Add data field if present 262 | if ( isset( $error['data'] ) ) { 263 | $response['error']['data'] = $error['data']; 264 | } 265 | } else { 266 | // Success response 267 | $response['result'] = (object) $result; 268 | } 269 | 270 | $json = wp_json_encode( $response, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); 271 | 272 | if ( false === $json ) { 273 | // Fallback for encoding errors 274 | return $this->create_error_response( 275 | $id, 276 | -32603, 277 | 'Internal error', 278 | 'Failed to encode response as JSON.' 279 | ); 280 | } 281 | 282 | return $json; 283 | } 284 | 285 | /** 286 | * Create a JSON-RPC error response. 287 | * 288 | * @param mixed $id The request ID (can be null). 289 | * @param int $code The error code. 290 | * @param string $message The error message. 291 | * @param string $data Optional error data. 292 | * 293 | * @return string The JSON error response string. 294 | */ 295 | private function create_error_response( $id, int $code, string $message, string $data = '' ): string { 296 | $response = array( 297 | 'jsonrpc' => '2.0', 298 | 'error' => array( 299 | 'code' => $code, 300 | 'message' => $message, 301 | ), 302 | 'id' => $id, 303 | ); 304 | 305 | if ( ! empty( $data ) ) { 306 | $response['error']['data'] = $data; 307 | } 308 | 309 | return wp_json_encode( $response, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ?: '{"jsonrpc":"2.0","error":{"code":-32603,"message":"Internal error"},"id":null}'; 310 | } 311 | 312 | /** 313 | * Create a request router for the server. 314 | * 315 | * @return \WP\MCP\Transport\Infrastructure\RequestRouter 316 | */ 317 | private function create_request_router(): RequestRouter { 318 | // Create transport context using server's infrastructure 319 | $context = $this->server->create_transport_context(); 320 | return $context->request_router; 321 | } 322 | 323 | /** 324 | * Log a message to stderr. 325 | * 326 | * @param string $message The message to log. 327 | */ 328 | private function log_to_stderr( string $message ): void { 329 | fwrite( STDERR, "[MCP STDIO Bridge] $message\n" ); // phpcs:ignore 330 | } 331 | 332 | /** 333 | * Get the server this bridge is exposing. 334 | * 335 | * @return \WP\MCP\Core\McpServer 336 | */ 337 | public function get_server(): McpServer { 338 | return $this->server; 339 | } 340 | } 341 | -------------------------------------------------------------------------------- /includes/Domain/Tools/McpTool.php: -------------------------------------------------------------------------------- 1 | ability = $ability; 111 | $this->name = $name; 112 | $this->title = $title; 113 | $this->description = $description; 114 | $this->input_schema = $input_schema; 115 | $this->output_schema = $output_schema; 116 | $this->annotations = $annotations; 117 | $this->metadata = $metadata; 118 | } 119 | 120 | /** 121 | * Get the ability name. 122 | * 123 | * @return \WP_Ability|\WP_Error WP_Ability instance on success, WP_Error on failure. 124 | */ 125 | public function get_ability() { 126 | $ability = wp_get_ability( $this->ability ); 127 | if ( ! $ability ) { 128 | return new \WP_Error( 129 | 'ability_not_found', 130 | sprintf( 131 | /* translators: %s: ability name */ 132 | esc_html__( "WordPress ability '%s' does not exist.", 'mcp-adapter' ), 133 | esc_html( $this->ability ) 134 | ) 135 | ); 136 | } 137 | return $ability; 138 | } 139 | 140 | /** 141 | * Get the tool name. 142 | * 143 | * @return string 144 | */ 145 | public function get_name(): string { 146 | return $this->name; 147 | } 148 | 149 | /** 150 | * Get the tool title. 151 | * 152 | * @return string|null 153 | */ 154 | public function get_title(): ?string { 155 | return $this->title; 156 | } 157 | 158 | /** 159 | * Get the tool description. 160 | * 161 | * @return string 162 | */ 163 | public function get_description(): string { 164 | return $this->description; 165 | } 166 | 167 | /** 168 | * Get the input schema. 169 | * 170 | * @return array 171 | */ 172 | public function get_input_schema(): array { 173 | return $this->input_schema; 174 | } 175 | 176 | /** 177 | * Get the output schema. 178 | * 179 | * @return array|null 180 | */ 181 | public function get_output_schema(): ?array { 182 | return $this->output_schema; 183 | } 184 | 185 | /** 186 | * Get the annotations. 187 | * 188 | * @return array 189 | */ 190 | public function get_annotations(): array { 191 | return $this->annotations; 192 | } 193 | 194 | /** 195 | * Get internal metadata for server-side processing. 196 | * 197 | * @return array 198 | */ 199 | public function get_metadata(): array { 200 | return $this->metadata; 201 | } 202 | 203 | /** 204 | * Set the tool title. 205 | * 206 | * @param string|null $title The title to set. 207 | * 208 | * @return void 209 | */ 210 | public function set_title( ?string $title ): void { 211 | $this->title = $title; 212 | } 213 | 214 | /** 215 | * Set the tool description. 216 | * 217 | * @param string $description The description to set. 218 | * 219 | * @return void 220 | */ 221 | public function set_description( string $description ): void { 222 | $this->description = $description; 223 | } 224 | 225 | /** 226 | * Set the input schema. 227 | * 228 | * @param array $input_schema The input schema to set. 229 | * 230 | * @return void 231 | */ 232 | public function set_input_schema( array $input_schema ): void { 233 | $this->input_schema = $input_schema; 234 | } 235 | 236 | /** 237 | * Set the output schema. 238 | * 239 | * @param array|null $output_schema The output schema to set. 240 | * 241 | * @return void 242 | */ 243 | public function set_output_schema( ?array $output_schema ): void { 244 | $this->output_schema = $output_schema; 245 | } 246 | 247 | /** 248 | * Set the annotations. 249 | * 250 | * @param array $annotations The annotations to set. 251 | * 252 | * @return void 253 | */ 254 | public function set_annotations( array $annotations ): void { 255 | $this->annotations = $annotations; 256 | } 257 | 258 | /** 259 | * Set internal metadata. 260 | * 261 | * @param array $metadata Internal metadata values. 262 | * 263 | * @return void 264 | */ 265 | public function set_metadata( array $metadata ): void { 266 | $this->metadata = $metadata; 267 | } 268 | 269 | /** 270 | * Add an annotation. 271 | * 272 | * @param string $key The annotation key. 273 | * @param mixed $value The annotation value. 274 | * 275 | * @return void 276 | */ 277 | public function add_annotation( string $key, $value ): void { 278 | $this->annotations[ $key ] = $value; 279 | } 280 | 281 | /** 282 | * Remove an annotation. 283 | * 284 | * @param string $key The annotation key to remove. 285 | * 286 | * @return void 287 | */ 288 | public function remove_annotation( string $key ): void { 289 | unset( $this->annotations[ $key ] ); 290 | } 291 | 292 | /** 293 | * Get the MCP server instance this tool belongs to. 294 | * 295 | * @return \WP\MCP\Core\McpServer 296 | */ 297 | public function get_mcp_server(): McpServer { 298 | if ( null === $this->mcp_server ) { 299 | throw new \RuntimeException( 'MCP server has not been set on this tool instance.' ); 300 | } 301 | 302 | return $this->mcp_server; 303 | } 304 | 305 | /** 306 | * Set the MCP server instance this tool belongs to. 307 | * 308 | * @param \WP\MCP\Core\McpServer $mcp_server The MCP server instance. 309 | * 310 | * @return void 311 | */ 312 | public function set_mcp_server( McpServer $mcp_server ): void { 313 | $this->mcp_server = $mcp_server; 314 | } 315 | 316 | /** 317 | * Convert the tool to an array representation according to MCP specification. 318 | * 319 | * @return array 320 | */ 321 | public function to_array(): array { 322 | $input_schema_for_json = empty( $this->input_schema ) 323 | ? array( 'type' => 'object' ) 324 | : $this->input_schema; 325 | 326 | $tool_data = array( 327 | 'name' => $this->name, 328 | 'description' => $this->description, 329 | 'inputSchema' => $input_schema_for_json, 330 | ); 331 | 332 | if ( ! is_null( $this->title ) ) { 333 | $tool_data['title'] = $this->title; 334 | } 335 | 336 | if ( ! is_null( $this->output_schema ) ) { 337 | $tool_data['outputSchema'] = $this->output_schema; 338 | } 339 | 340 | if ( ! empty( $this->annotations ) ) { 341 | $tool_data['annotations'] = $this->annotations; 342 | } 343 | 344 | return $tool_data; 345 | } 346 | 347 | /** 348 | * Create an McpTool instance from an array. 349 | * 350 | * @param array $data Array containing tool data. 351 | * @param \WP\MCP\Core\McpServer $mcp_server The MCP server instance. 352 | * 353 | * @return self|\WP_Error Returns a new McpTool instance or WP_Error if validation fails. 354 | */ 355 | public static function from_array( array $data, McpServer $mcp_server ) { 356 | $tool = new self( 357 | $data['ability'] ?? '', 358 | $data['name'] ?? '', 359 | $data['description'] ?? '', 360 | $data['inputSchema'] ?? array(), 361 | $data['title'] ?? null, 362 | $data['outputSchema'] ?? null, 363 | $data['annotations'] ?? array(), 364 | $data['_metadata'] ?? array() 365 | ); 366 | $tool->set_mcp_server( $mcp_server ); 367 | 368 | return $tool->validate( "McpTool::from_array::{$data['name']}" ); 369 | } 370 | 371 | /** 372 | * Validate the tool according to MCP specification requirements. 373 | * Uses the centralized McpToolValidator for consistent validation. 374 | * 375 | * @param string $context Optional context for error messages. 376 | * 377 | * @return self|\WP_Error Returns the validated tool instance or WP_Error if validation fails. 378 | */ 379 | public function validate( string $context = '' ) { 380 | if ( null === $this->mcp_server ) { 381 | return new \WP_Error( 382 | 'tool_missing_mcp_server', 383 | esc_html__( 'MCP server must be set before validating a tool.', 'mcp-adapter' ) 384 | ); 385 | } 386 | 387 | if ( ! $this->mcp_server->is_mcp_validation_enabled() ) { 388 | return $this; 389 | } 390 | 391 | $context_to_use = $context ?: "McpTool::{$this->name}"; 392 | $validation_result = McpToolValidator::validate_tool_instance( $this, $context_to_use ); 393 | 394 | if ( is_wp_error( $validation_result ) ) { 395 | return $validation_result; 396 | } 397 | 398 | return $this; 399 | } 400 | } 401 | -------------------------------------------------------------------------------- /includes/Domain/Tools/McpToolValidator.php: -------------------------------------------------------------------------------- 1 | to_array(), $context ); 64 | } 65 | 66 | /** 67 | * Get validation error details for debugging purposes. 68 | * This is the core validation method - all other validation methods use this. 69 | * 70 | * @param array $tool_data The tool data to validate. 71 | * 72 | * @return array Array of validation errors, empty if valid. 73 | */ 74 | public static function get_validation_errors( array $tool_data ): array { 75 | $errors = array(); 76 | 77 | // Sanitize string inputs. 78 | if ( isset( $tool_data['name'] ) && is_string( $tool_data['name'] ) ) { 79 | $tool_data['name'] = trim( $tool_data['name'] ); 80 | } 81 | if ( isset( $tool_data['description'] ) && is_string( $tool_data['description'] ) ) { 82 | $tool_data['description'] = trim( $tool_data['description'] ); 83 | } 84 | if ( isset( $tool_data['title'] ) && is_string( $tool_data['title'] ) ) { 85 | $tool_data['title'] = trim( $tool_data['title'] ); 86 | } 87 | 88 | // Check the required fields. 89 | if ( empty( $tool_data['name'] ) || ! is_string( $tool_data['name'] ) || ! McpValidator::validate_tool_or_prompt_name( $tool_data['name'] ) ) { 90 | $errors[] = __( 'Tool name is required and must only contain letters, numbers, hyphens (-), and underscores (_), and be 255 characters or less', 'mcp-adapter' ); 91 | } 92 | 93 | if ( empty( $tool_data['description'] ) || ! is_string( $tool_data['description'] ) ) { 94 | $errors[] = __( 'Tool description is required and must be a non-empty string', 'mcp-adapter' ); 95 | } 96 | 97 | // Validate inputSchema (required field). 98 | $input_schema_errors = self::get_schema_validation_errors( $tool_data['inputSchema'] ?? null, 'inputSchema' ); 99 | if ( ! empty( $input_schema_errors ) ) { 100 | $errors = array_merge( $errors, $input_schema_errors ); 101 | } 102 | 103 | // Check optional fields if present. 104 | if ( isset( $tool_data['title'] ) && ! is_string( $tool_data['title'] ) ) { 105 | $errors[] = __( 'Tool title must be a string if provided', 'mcp-adapter' ); 106 | } 107 | 108 | // Validate outputSchema (optional field). 109 | if ( isset( $tool_data['outputSchema'] ) ) { 110 | $output_schema_errors = self::get_schema_validation_errors( $tool_data['outputSchema'], 'outputSchema' ); 111 | if ( ! empty( $output_schema_errors ) ) { 112 | $errors = array_merge( $errors, $output_schema_errors ); 113 | } 114 | } 115 | 116 | // Validate annotations structure if present. 117 | if ( isset( $tool_data['annotations'] ) ) { 118 | if ( ! is_array( $tool_data['annotations'] ) ) { 119 | $errors[] = __( 'Tool annotations must be an array if provided', 'mcp-adapter' ); 120 | } else { 121 | // Validate shared annotations (audience, lastModified, priority). 122 | $shared_annotation_errors = McpValidator::get_annotation_validation_errors( $tool_data['annotations'] ); 123 | if ( ! empty( $shared_annotation_errors ) ) { 124 | $errors = array_merge( $errors, $shared_annotation_errors ); 125 | } 126 | 127 | // Validate tool-specific annotations (readOnlyHint, destructiveHint, etc.). 128 | $tool_annotation_errors = McpValidator::get_tool_annotation_validation_errors( $tool_data['annotations'] ); 129 | if ( ! empty( $tool_annotation_errors ) ) { 130 | $errors = array_merge( $errors, $tool_annotation_errors ); 131 | } 132 | } 133 | } 134 | 135 | return $errors; 136 | } 137 | 138 | /** 139 | * Get detailed validation errors for a schema object. 140 | * 141 | * @param array|mixed $schema The schema to validate. 142 | * @param string $field_name The name of the field being validated (for error messages). 143 | * 144 | * @return array Array of validation errors, empty if valid. 145 | */ 146 | private static function get_schema_validation_errors( $schema, string $field_name ): array { 147 | // Normalize stdClass to array for validation, and reject scalars/null. 148 | if ( $schema instanceof stdClass ) { 149 | $schema = (array) $schema; 150 | } 151 | 152 | // Schema must be an array/object - early return for performance. 153 | if ( ! is_array( $schema ) ) { 154 | return array( 155 | sprintf( 156 | /* translators: %s: field name (inputSchema or outputSchema) */ 157 | __( 'Tool %s must be a valid JSON schema object', 'mcp-adapter' ), 158 | $field_name 159 | ), 160 | ); 161 | } 162 | 163 | $errors = array(); 164 | 165 | // Input schemas commonly describe an object of arguments. Allow omitted type (empty schema) or type 'object'. 166 | // For output schemas, do not enforce a specific type; any valid JSON Schema is acceptable per MCP. 167 | if ( 'inputSchema' === $field_name && isset( $schema['type'] ) && 'object' !== $schema['type'] ) { 168 | $errors[] = sprintf( 169 | /* translators: %s: field name */ 170 | __( 'Tool %s, if specifying a type, must use type \'object\'', 'mcp-adapter' ), 171 | $field_name 172 | ); 173 | } 174 | 175 | // If properties exist, they must be an array/object. 176 | if ( isset( $schema['properties'] ) && ! is_array( $schema['properties'] ) ) { 177 | $errors[] = sprintf( 178 | /* translators: %s: field name */ 179 | __( 'Tool %s properties must be an object/array', 'mcp-adapter' ), 180 | $field_name 181 | ); 182 | } 183 | 184 | // If required exists, it must be an array. 185 | if ( isset( $schema['required'] ) && ! is_array( $schema['required'] ) ) { 186 | $errors[] = sprintf( 187 | /* translators: %s: field name */ 188 | __( 'Tool %s required field must be an array', 'mcp-adapter' ), 189 | $field_name 190 | ); 191 | } 192 | 193 | // If properties are provided, validate their basic structure. 194 | if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) { 195 | foreach ( $schema['properties'] as $property_name => $property ) { 196 | if ( ! is_array( $property ) ) { 197 | $errors[] = sprintf( 198 | /* translators: %1$s: field name, %2$s: property name */ 199 | __( 'Tool %1$s property \'%2$s\' must be an object', 'mcp-adapter' ), 200 | $field_name, 201 | $property_name 202 | ); 203 | continue; 204 | } 205 | 206 | // Each property should have a type (though not strictly required by JSON Schema). 207 | if ( ! isset( $property['type'] ) || is_string( $property['type'] ) || is_array( $property['type'] ) ) { 208 | continue; 209 | } 210 | 211 | // If type is neither string nor array, it's invalid. 212 | $errors[] = sprintf( 213 | /* translators: %1$s: field name, %2$s: property name */ 214 | __( 'Tool %1$s property \'%2$s\' type must be a string or array of strings (union type)', 'mcp-adapter' ), 215 | $field_name, 216 | $property_name 217 | ); 218 | } 219 | } 220 | 221 | // If required array is provided, validate its structure. 222 | if ( isset( $schema['required'] ) && is_array( $schema['required'] ) ) { 223 | foreach ( $schema['required'] as $required_field ) { 224 | if ( ! is_string( $required_field ) ) { 225 | $errors[] = sprintf( 226 | /* translators: %s: field name */ 227 | __( 'Tool %s required field names must be strings', 'mcp-adapter' ), 228 | $field_name 229 | ); 230 | continue; 231 | } 232 | 233 | // Check that required fields exist in properties (if properties are defined). 234 | if ( ! isset( $schema['properties'] ) || isset( $schema['properties'][ $required_field ] ) ) { 235 | continue; 236 | } 237 | 238 | $errors[] = sprintf( 239 | /* translators: %1$s: field name, %2$s: required field */ 240 | __( 'Tool %1$s required field \'%2$s\' does not exist in properties', 'mcp-adapter' ), 241 | $field_name, 242 | $required_field 243 | ); 244 | } 245 | } 246 | 247 | return $errors; 248 | } 249 | 250 | /** 251 | * Validate that the tool name is unique within the server. 252 | * 253 | * @param \WP\MCP\Domain\Tools\McpTool $tool The tool instance to validate. 254 | * @param string $context Optional context for error messages. 255 | * 256 | * @return bool|\WP_Error True if unique, WP_Error if the tool name is not unique. 257 | */ 258 | public static function validate_tool_uniqueness( McpTool $tool, string $context = '' ) { 259 | $this_tool_name = $tool->get_name(); 260 | $server = $tool->get_mcp_server(); 261 | $existing_tool = $server->get_tool( $this_tool_name ); 262 | 263 | // Check if a tool with this name already exists. 264 | if ( $existing_tool ) { 265 | $error_message = $context ? "[{$context}] " : ''; 266 | $error_message .= sprintf( 267 | /* translators: %1$s: tool name, %2$s: server ID */ 268 | __( 'Tool name \'%1$s\' is not unique. A tool with this name already exists on server \'%2$s\'.', 'mcp-adapter' ), 269 | $this_tool_name, 270 | $server->get_server_id() 271 | ); 272 | return new \WP_Error( 'tool_not_unique', esc_html( $error_message ) ); 273 | } 274 | 275 | return true; 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /includes/Core/McpAdapter.php: -------------------------------------------------------------------------------- 1 | maybe_create_default_server(); 82 | 83 | do_action( 'mcp_adapter_init', $this ); 84 | $this->register_wp_cli_commands(); 85 | self::$initialized = true; 86 | } 87 | 88 | /** 89 | * Create and register a new MCP server. 90 | * 91 | * @param string $server_id Unique identifier for the server. 92 | * @param string $server_route_namespace Server route namespace. 93 | * @param string $server_route Server route. 94 | * @param string $server_name Server name. 95 | * @param string $server_description Server description. 96 | * @param string $server_version Server version. 97 | * @param array $mcp_transports Array of classes that extend the BaseTransport. 98 | * @param class-string<\WP\MCP\Infrastructure\ErrorHandling\Contracts\McpErrorHandlerInterface> $error_handler The error handler class name. If null, NullMcpErrorHandler will be used. 99 | * @param class-string<\WP\MCP\Infrastructure\Observability\Contracts\McpObservabilityHandlerInterface> $observability_handler The observability handler class name. If null, NullMcpObservabilityHandler will be used. 100 | * @param array $tools Ability names to register as tools. 101 | * @param array $resources Resources to register. 102 | * @param array $prompts Prompts to register. 103 | * @param callable|null $transport_permission_callback Optional custom permission callback for transport-level authentication. If null, defaults to is_user_logged_in(). 104 | * 105 | * @return \WP\MCP\Core\McpAdapter|\WP_Error McpAdapter instance on success, WP_Error on failure. 106 | */ 107 | public function create_server( string $server_id, string $server_route_namespace, string $server_route, string $server_name, string $server_description, string $server_version, array $mcp_transports, ?string $error_handler, ?string $observability_handler = null, array $tools = array(), array $resources = array(), array $prompts = array(), ?callable $transport_permission_callback = null ) { 108 | // Use NullMcpErrorHandler if no error handler is provided. 109 | if ( ! $error_handler ) { 110 | $error_handler = NullMcpErrorHandler::class; 111 | } 112 | 113 | // Validate error handler class exists and implements McpErrorHandlerInterface. 114 | if ( ! class_exists( $error_handler ) ) { 115 | return new \WP_Error( 116 | 'invalid_error_handler', 117 | sprintf( 118 | /* translators: %s: error handler class name */ 119 | esc_html__( 'Error handler class "%s" does not exist.', 'mcp-adapter' ), 120 | esc_html( $error_handler ) 121 | ) 122 | ); 123 | } 124 | 125 | if ( ! in_array( McpErrorHandlerInterface::class, class_implements( $error_handler ) ?: array(), true ) ) { 126 | return new \WP_Error( 127 | 'invalid_error_handler', 128 | sprintf( 129 | /* translators: %s: error handler class name */ 130 | esc_html__( 'Error handler class "%s" must implement the McpErrorHandlerInterface.', 'mcp-adapter' ), 131 | esc_html( $error_handler ) 132 | ) 133 | ); 134 | } 135 | 136 | // Use NullMcpObservabilityHandler if no observability handler is provided. 137 | if ( ! $observability_handler ) { 138 | $observability_handler = NullMcpObservabilityHandler::class; 139 | } 140 | 141 | // Validate observability handler class exists and implements McpObservabilityHandlerInterface. 142 | if ( ! class_exists( $observability_handler ) ) { 143 | return new \WP_Error( 144 | 'invalid_observability_handler', 145 | sprintf( 146 | /* translators: %s: observability handler class name */ 147 | esc_html__( 'Observability handler class "%s" does not exist.', 'mcp-adapter' ), 148 | esc_html( $observability_handler ) 149 | ) 150 | ); 151 | } 152 | 153 | if ( ! in_array( McpObservabilityHandlerInterface::class, class_implements( $observability_handler ) ?: array(), true ) ) { 154 | return new \WP_Error( 155 | 'invalid_observability_handler', 156 | sprintf( 157 | /* translators: %s: observability handler class name */ 158 | esc_html__( 'Observability handler class "%s" must implement the McpObservabilityHandlerInterface interface.', 'mcp-adapter' ), 159 | esc_html( $observability_handler ) 160 | ) 161 | ); 162 | } 163 | 164 | if ( ! doing_action( 'mcp_adapter_init' ) ) { 165 | _doing_it_wrong( 166 | __FUNCTION__, 167 | esc_html__( 'MCP Servers must be created during the "mcp_adapter_init" action. Hook into "mcp_adapter_init" to register your server.', 'mcp-adapter' ), 168 | '0.1.0' 169 | ); 170 | return new \WP_Error( 171 | 'invalid_timing', 172 | esc_html__( 'MCP Server creation must be done during mcp_adapter_init action.', 'mcp-adapter' ) 173 | ); 174 | } 175 | 176 | if ( isset( $this->servers[ $server_id ] ) ) { 177 | _doing_it_wrong( 178 | __FUNCTION__, 179 | sprintf( 180 | // translators: %s: server ID 181 | esc_html__( 'Server with ID "%s" already exists. Each server must have a unique ID.', 'mcp-adapter' ), 182 | esc_html( $server_id ) 183 | ), 184 | '0.1.0' 185 | ); 186 | return new \WP_Error( 187 | 'duplicate_server_id', 188 | // translators: %s: server ID. 189 | sprintf( esc_html__( 'Server with ID "%s" already exists.', 'mcp-adapter' ), esc_html( $server_id ) ) 190 | ); 191 | } 192 | 193 | // Create server with tools, resources, and prompts - let server handle all registration logic. 194 | $server = new McpServer( 195 | $server_id, 196 | $server_route_namespace, 197 | $server_route, 198 | $server_name, 199 | $server_description, 200 | $server_version, 201 | $mcp_transports, 202 | $error_handler, 203 | $observability_handler, 204 | $tools, 205 | $resources, 206 | $prompts, 207 | $transport_permission_callback 208 | ); 209 | 210 | // Track server creation. 211 | $server->get_observability_handler()->record_event( 212 | 'mcp.server.created', 213 | array( 214 | 'status' => 'success', 215 | 'server_id' => $server_id, 216 | 'transport_count' => count( $mcp_transports ), 217 | 'tools_count' => count( $tools ), 218 | 'resources_count' => count( $resources ), 219 | 'prompts_count' => count( $prompts ), 220 | ) 221 | ); 222 | 223 | // Add server to registry. 224 | $this->servers[ $server_id ] = $server; 225 | 226 | return $this; 227 | } 228 | 229 | /** 230 | * Get a server by ID. 231 | * 232 | * @param string $server_id Server ID. 233 | * 234 | * @return \WP\MCP\Core\McpServer|null 235 | */ 236 | public function get_server( string $server_id ): ?McpServer { 237 | return $this->servers[ $server_id ] ?? null; 238 | } 239 | 240 | /** 241 | * Get all registered servers 242 | * 243 | * @return \WP\MCP\Core\McpServer[] 244 | */ 245 | public function get_servers(): array { 246 | return $this->servers; 247 | } 248 | 249 | /** 250 | * Conditionally create the default server based on filter. 251 | * 252 | * @internal For use by adapter initialization only. 253 | */ 254 | private function maybe_create_default_server(): void { 255 | // Allow disabling default server creation 256 | if ( ! apply_filters( 'mcp_adapter_create_default_server', true ) ) { 257 | return; 258 | } 259 | 260 | // Register category before abilities 261 | add_action( 'wp_abilities_api_categories_init', array( $this, 'register_default_category' ) ); 262 | add_action( 'wp_abilities_api_init', array( $this, 'register_default_abilities' ) ); 263 | 264 | add_action( 'mcp_adapter_init', array( DefaultServerFactory::class, 'create' ) ); 265 | } 266 | 267 | /** 268 | * Register the default MCP category. 269 | * 270 | * @return void 271 | */ 272 | public function register_default_category(): void { 273 | wp_register_ability_category( 274 | 'mcp-adapter', 275 | array( 276 | 'label' => 'MCP Adapter', 277 | 'description' => 'Abilities for the MCP Adapter', 278 | ) 279 | ); 280 | } 281 | 282 | /** 283 | * Register the default MCP abilities. 284 | * 285 | * @return void 286 | */ 287 | public function register_default_abilities(): void { 288 | // Register the three core MCP abilities 289 | DiscoverAbilitiesAbility::register(); 290 | GetAbilityInfoAbility::register(); 291 | ExecuteAbilityAbility::register(); 292 | } 293 | 294 | /** 295 | * Register WP-CLI commands if WP-CLI is available 296 | * 297 | * @internal For use by adapter initialization only. 298 | */ 299 | private function register_wp_cli_commands(): void { 300 | // Only register if WP-CLI is available 301 | if ( ! defined( 'WP_CLI' ) || ! constant( 'WP_CLI' ) ) { 302 | return; 303 | } 304 | 305 | if ( ! class_exists( 'WP_CLI' ) ) { 306 | return; 307 | } 308 | 309 | \WP_CLI::add_command( 310 | 'mcp-adapter', 311 | McpCommand::class, 312 | array( 313 | 'shortdesc' => 'Manage MCP servers via WP-CLI.', 314 | 'longdesc' => 'Commands for managing and serving MCP servers, including STDIO transport.', 315 | ) 316 | ); 317 | } 318 | } 319 | --------------------------------------------------------------------------------