# Directory Structure ``` ├── composer.json ├── composer.lock ├── LICENSE ├── mcp.php ├── phpstan.neon.dist ├── README.md └── src ├── functions.php ├── MCP │ ├── Server.php │ └── Servers │ └── WordPress │ ├── MediaManager.php │ ├── Tools │ │ ├── CommunityEvents.php │ │ ├── Dummy.php │ │ ├── RestApi.php │ │ └── RouteInformation.php │ └── WordPress.php └── RestController.php ``` # Files -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # MCP Server for WordPress 2 | 3 | [](https://github.com/mcp-wp/mcp-server/pulse/monthly) 4 | [](https://codecov.io/gh/mcp-wp/mcp-server) 5 | [](https://github.com/mcp-wp/mcp-server/blob/main/LICENSE) 6 | 7 | [Model Context Protocol](https://modelcontextprotocol.io/) server using the WordPress REST API. 8 | 9 | Try it by installing and activating the latest nightly build on your own WordPress website: 10 | 11 | [](https://mcp-wp.github.io/mcp-server/mcp.zip) 12 | 13 | ## Description 14 | 15 | This WordPress plugin aims to implement the new [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http), as described in the latest MCP specification. 16 | 17 | Under the hood it uses the [`logiscape/mcp-sdk-php`](https://github.com/logiscape/mcp-sdk-php) package to set up a fully functioning MCP server. Then, this functionality is exposed through a new `wp-json/mcp/v1/mcp` REST API route in WordPress. 18 | 19 | Note: the Streamable HTTP transport is not fully implemented yet and there are no tests. So it might not 100% work as expected. 20 | 21 | ## Usage 22 | 23 | Given that no other MCP client supports the new Streamable HTTP transport yet, this plugin works best in companion with the [WP-CLI AI command](https://github.com/mcp-wp/ai-command). 24 | 25 | 1. Run `wp plugin install --activate https://github.com/mcp-wp/mcp-server/archive/refs/heads/main.zip` 26 | 2. Run `wp plugin install --activate ai-services` 27 | 3. Run `wp package install mcp-wp/ai-command:dev-main` 28 | 4. Run `wp mcp server add "mysite" "https://example.com/wp-json/mcp/v1/mcp"` 29 | 5. Run `wp ai "Greet my friend Pascal"` or so 30 | 31 | Note: The WP-CLI command also works on a local WordPress installation without this plugin. 32 | ``` -------------------------------------------------------------------------------- /mcp.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | /** 3 | * Plugin Name: MCP Server for WordPress 4 | * Plugin URI: https://github.com/swissspidy/mcp 5 | * Description: MCP server implementation using the WordPress REST API. 6 | * Version: 0.1.0 7 | * Author: Pascal Birchler 8 | * Author URI: https://pascalbirchler.com 9 | * License: Apache-2.0 10 | * License URI: https://www.apache.org/licenses/LICENSE-2.0 11 | * Text Domain: mcp 12 | * Requires at least: 6.7 13 | * Requires PHP: 8.2 14 | * Update URI: https://mcp-wp.github.io/mcp-server/update.json 15 | * 16 | * @package McpWp 17 | */ 18 | 19 | require_once __DIR__ . '/vendor/autoload.php'; 20 | 21 | register_activation_hook( __FILE__, 'McpWp\\activate_plugin' ); 22 | register_deactivation_hook( __FILE__, 'McpWp\\deactivate_plugin' ); 23 | 24 | \McpWp\boot(); 25 | ``` -------------------------------------------------------------------------------- /src/MCP/Servers/WordPress/Tools/Dummy.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace McpWp\MCP\Servers\WordPress\Tools; 4 | 5 | use Mcp\Types\TextContent; 6 | use McpWp\MCP\Server; 7 | 8 | /** 9 | * @phpstan-import-type ToolDefinition from Server 10 | */ 11 | readonly class Dummy { 12 | 13 | /** 14 | * Returns a list of dummy tools for testing. 15 | * 16 | * @return array<int, ToolDefinition> Tools. 17 | */ 18 | public function get_tools(): array { 19 | $tools = []; 20 | 21 | $tools[] = [ 22 | 'name' => 'greet-user', 23 | 'description' => 'Greet a given user by their name', 24 | 'inputSchema' => [ 25 | 'type' => 'object', 26 | 'properties' => [ 27 | 'name' => [ 28 | 'type' => 'string', 29 | 'description' => 'Name', 30 | ], 31 | ], 32 | 'required' => [ 'name' ], 33 | ], 34 | 'callback' => static function ( $arguments ) { 35 | $name = $arguments['name']; 36 | 37 | return new TextContent( 38 | "Hello my friend, $name" 39 | ); 40 | }, 41 | ]; 42 | 43 | return $tools; 44 | } 45 | } 46 | ``` -------------------------------------------------------------------------------- /src/MCP/Servers/WordPress/MediaManager.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace McpWp\MCP\Servers\WordPress; 4 | 5 | class MediaManager { 6 | 7 | public static function upload_to_media_library( string $media_path ): \WP_Error|int { 8 | // Get WordPress upload directory information 9 | $upload_dir = wp_upload_dir(); 10 | 11 | // Get the file name from the path 12 | $file_name = basename( $media_path ); 13 | 14 | // Copy file to the upload directory 15 | $new_file_path = $upload_dir['path'] . '/' . $file_name; 16 | copy( $media_path, $new_file_path ); 17 | 18 | // Prepare attachment data 19 | $wp_filetype = wp_check_filetype( $file_name, null ); 20 | $attachment = array( 21 | 'post_mime_type' => $wp_filetype['type'], 22 | 'post_title' => sanitize_file_name( $file_name ), 23 | 'post_content' => '', 24 | 'post_status' => 'inherit', 25 | ); 26 | 27 | // Insert the attachment 28 | $attach_id = wp_insert_attachment( $attachment, $new_file_path ); 29 | 30 | // Generate attachment metadata 31 | // @phpstan-ignore requireOnce.fileNotFound 32 | require_once ABSPATH . 'wp-admin/includes/image.php'; 33 | $attach_data = wp_generate_attachment_metadata( $attach_id, $new_file_path ); 34 | wp_update_attachment_metadata( $attach_id, $attach_data ); 35 | 36 | return $attach_id; 37 | } 38 | } 39 | ``` -------------------------------------------------------------------------------- /src/MCP/Servers/WordPress/WordPress.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace McpWp\MCP\Servers\WordPress; 4 | 5 | use Mcp\Types\Resource; 6 | use Mcp\Types\ResourceTemplate; 7 | use McpWp\MCP\Server; 8 | use McpWp\MCP\Servers\WordPress\Tools\CommunityEvents; 9 | use McpWp\MCP\Servers\WordPress\Tools\Dummy; 10 | use McpWp\MCP\Servers\WordPress\Tools\RestApi; 11 | use Psr\Log\LoggerInterface; 12 | 13 | class WordPress extends Server { 14 | public function __construct( ?LoggerInterface $logger = null ) { 15 | parent::__construct( 'WordPress', $logger ); 16 | 17 | $all_tools = [ 18 | ...( new RestApi( $this->logger ) )->get_tools(), 19 | ...( new CommunityEvents() )->get_tools(), 20 | ...( new Dummy() )->get_tools(), 21 | ]; 22 | 23 | /** 24 | * Filters all the tools exposed by the WordPress MCP server. 25 | * 26 | * @param array $all_tools MCP tools. 27 | */ 28 | $all_tools = apply_filters( 'mcp_wp_wordpress_tools', $all_tools ); 29 | 30 | foreach ( $all_tools as $tool ) { 31 | try { 32 | $this->register_tool( $tool ); 33 | } catch ( \Exception $e ) { 34 | $this->logger->debug( $e->getMessage() ); 35 | } 36 | } 37 | 38 | /** 39 | * Fires after tools have been registered in the WordPress MCP server. 40 | * 41 | * Can be used to register additional tools. 42 | * 43 | * @param Server $server WordPress MCP server instance. 44 | */ 45 | do_action( 'mcp_wp_wordpress_tools_loaded', $this ); 46 | 47 | $this->register_resource( 48 | new Resource( 49 | 'Greeting Text', 50 | 'example://greeting', 51 | 'A simple greeting message', 52 | 'text/plain' 53 | ) 54 | ); 55 | 56 | $this->register_resource_template( 57 | new ResourceTemplate( 58 | 'Attachment', 59 | 'media://{id}', 60 | 'WordPress attachment', 61 | 'application/octet-stream' 62 | ) 63 | ); 64 | } 65 | } 66 | ``` -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "mcp-wp/mcp-server", 3 | "description": "MCP Server for WordPress", 4 | "license": "Apache-2.0", 5 | "type": "wordpress-plugin", 6 | "authors": [ 7 | { 8 | "name": "Pascal Birchler", 9 | "email": "[email protected]", 10 | "homepage": "https://pascalbirchler.com", 11 | "role": "Developer" 12 | } 13 | ], 14 | "require": { 15 | "php": "^8.2", 16 | "logiscape/mcp-sdk-php": "dev-main" 17 | }, 18 | "require-dev": { 19 | "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0", 20 | "php-stubs/wordpress-tests-stubs": "dev-master", 21 | "phpcompatibility/phpcompatibility-wp": "^2.0", 22 | "phpstan/extension-installer": "^1.3", 23 | "roave/security-advisories": "dev-latest", 24 | "szepeviktor/phpstan-wordpress": "^v2.0.1", 25 | "wp-coding-standards/wpcs": "^3.0.1", 26 | "yoast/phpunit-polyfills": "^4.0.0", 27 | "johnbillion/wp-compat": "^1.1", 28 | "phpstan/phpstan-strict-rules": "^2.0" 29 | }, 30 | "config": { 31 | "allow-plugins": { 32 | "dealerdirect/phpcodesniffer-composer-installer": true, 33 | "phpstan/extension-installer": true 34 | }, 35 | "platform": { 36 | "php": "8.2" 37 | }, 38 | "sort-packages": true 39 | }, 40 | "autoload": { 41 | "psr-4": { 42 | "McpWp\\": "src/" 43 | }, 44 | "files": [ 45 | "src/functions.php" 46 | ] 47 | }, 48 | "autoload-dev": { 49 | "psr-4": { 50 | "McpWp\\Tests\\": "tests/phpunit/tests/", 51 | "McpWp\\Tests_Includes\\": "tests/phpunit/includes/" 52 | } 53 | }, 54 | "scripts": { 55 | "format": "vendor/bin/phpcbf --report-summary --report-source .", 56 | "lint": "vendor/bin/phpcs --report-summary --report-source .", 57 | "phpstan": "phpstan analyse --memory-limit=2048M", 58 | "test": "vendor/bin/phpunit", 59 | "test:multisite": "vendor/bin/phpunit -c phpunit-multisite.xml.dist" 60 | } 61 | } 62 | ``` -------------------------------------------------------------------------------- /src/MCP/Servers/WordPress/Tools/RouteInformation.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace McpWp\MCP\Servers\WordPress\Tools; 6 | 7 | use BadMethodCallException; 8 | use WP_REST_Controller; 9 | use WP_REST_Posts_Controller; 10 | use WP_REST_Taxonomies_Controller; 11 | use WP_REST_Users_Controller; 12 | 13 | /** 14 | * RouteInformation helper class. 15 | */ 16 | readonly class RouteInformation { 17 | /** 18 | * Class constructor. 19 | * 20 | * @param string $route Route name. 21 | * @param string $method Method. 22 | * @param string $title Schema title. 23 | */ 24 | public function __construct( 25 | private string $route, 26 | private string $method, 27 | private string $title, 28 | ) {} 29 | 30 | /** 31 | * Returns a tool name based on the route and method. 32 | * 33 | * Example: DELETE wp/v2/users/me -> delete_wp_v2_users_me 34 | * 35 | * @return string Tool name. 36 | */ 37 | public function get_name(): string { 38 | $route = $this->route; 39 | 40 | preg_match_all( '/\(?P<(\w+)>/', $this->route, $matches ); 41 | 42 | foreach ( $matches[1] as $match ) { 43 | $route = (string) preg_replace( 44 | '/(\(\?P<' . $match . '>.*\))/', 45 | 'p_' . $match, 46 | $route, 47 | 1 48 | ); 49 | } 50 | 51 | $suffix = sanitize_title( $route ); 52 | 53 | if ( '' === $suffix ) { 54 | $suffix = 'index'; 55 | } 56 | 57 | return strtolower( str_replace( '-', '_', $this->method . '_' . $suffix ) ); 58 | } 59 | 60 | /** 61 | * Returns a description based on the route and method. 62 | * 63 | * Examples: 64 | * 65 | * GET /wp/v2/posts -> Get a list of post items 66 | * GET /wp/v2/posts/(?P<id>[\d]+) -> Get a single post item 67 | */ 68 | public function get_description(): string { 69 | $verb = match ( $this->method ) { 70 | 'POST' => 'Create', 71 | 'PUT', 'PATCH' => 'Update', 72 | 'DELETE' => 'Delete', 73 | default => 'Get', 74 | }; 75 | 76 | $is_singular = str_ends_with( $this->route, '(?P<id>[\d]+)' ) || 'POST' === $this->method; 77 | 78 | $determiner = $is_singular ? 'a single' : 'a list of'; 79 | 80 | $title = '' !== $this->title ? "{$this->title} item" : 'item'; 81 | $title = $is_singular ? $title : $title . 's'; 82 | 83 | return $verb . ' ' . $determiner . ' ' . $title; 84 | } 85 | } 86 | ``` -------------------------------------------------------------------------------- /src/MCP/Servers/WordPress/Tools/CommunityEvents.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace McpWp\MCP\Servers\WordPress\Tools; 4 | 5 | use Mcp\Types\TextContent; 6 | use McpWp\MCP\Server; 7 | use WP_Community_Events; 8 | 9 | /** 10 | * CommunityEvents tool class. 11 | * 12 | * Demonstrates how an additional tool can be added to 13 | * provide some other information from WordPress beyond 14 | * the REST API routes. 15 | * 16 | * @phpstan-import-type ToolDefinition from Server 17 | */ 18 | readonly class CommunityEvents { 19 | /** 20 | * Returns a list of tools. 21 | * 22 | * @return array<int, ToolDefinition> Tools. 23 | */ 24 | public function get_tools(): array { 25 | $tools = []; 26 | 27 | $tools[] = [ 28 | 'name' => 'fetch_wp_community_events', 29 | 'description' => __( 'Fetches upcoming WordPress community events near a specified city or the user\'s current location. If no events are found in the exact location, nearby events within a specific radius will be considered.', 'mcp' ), 30 | 'inputSchema' => [ 31 | 'type' => 'object', 32 | 'properties' => [ 33 | 'location' => [ 34 | 'type' => 'string', 35 | 'description' => __( 'City name or "near me" for auto-detected location. If no events are found in the exact location, the tool will also consider nearby events within a specified radius (default: 100 km).', 'mcp' ), 36 | ], 37 | ], 38 | 'required' => [ 'location' ], 39 | ], 40 | 'callback' => static function ( $params ) { 41 | $location_input = strtolower( trim( $params['location'] ) ); 42 | 43 | if ( ! class_exists( 'WP_Community_Events' ) ) { 44 | // @phpstan-ignore requireOnce.fileNotFound 45 | require_once ABSPATH . 'wp-admin/includes/class-wp-community-events.php'; 46 | } 47 | 48 | $location = [ 49 | 'description' => $location_input, 50 | ]; 51 | 52 | $events_instance = new WP_Community_Events( 0, $location ); 53 | 54 | // Get events from WP_Community_Events 55 | $events = $events_instance->get_events( $location_input ); 56 | 57 | // Check for WP_Error 58 | if ( is_wp_error( $events ) ) { 59 | return $events; 60 | } 61 | 62 | return new TextContent( 63 | json_encode( $events['events'], JSON_THROW_ON_ERROR ) 64 | ); 65 | }, 66 | ]; 67 | 68 | return $tools; 69 | } 70 | } 71 | ``` -------------------------------------------------------------------------------- /src/functions.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | /** 3 | * Collection of functions. 4 | * 5 | * @package McpWp 6 | */ 7 | 8 | declare(strict_types = 1); 9 | 10 | namespace McpWp; 11 | use WP_User; 12 | use function add_action; 13 | 14 | /** 15 | * Bootstrap function. 16 | * 17 | * @return void 18 | */ 19 | function boot(): void { 20 | add_action( 'init', __NAMESPACE__ . '\register_session_post_type' ); 21 | add_action( 'rest_api_init', __NAMESPACE__ . '\register_rest_routes' ); 22 | 23 | add_action( 'mcp_sessions_cleanup', __NAMESPACE__ . '\delete_old_sessions' ); 24 | 25 | add_filter( 'update_plugins_mcp-wp.github.io', __NAMESPACE__ . '\filter_update_plugins', 10, 2 ); 26 | 27 | add_filter( 'determine_current_user', __NAMESPACE__ . '\validate_bearer_token', 30 ); 28 | } 29 | 30 | /** 31 | * Filters the update response for this plugin. 32 | * 33 | * Allows downloading updates from GitHub. 34 | * 35 | * @codeCoverageIgnore 36 | * 37 | * @param array<string,mixed>|false $update The plugin update data with the latest details. Default false. 38 | * @param array<string,string> $plugin_data Plugin headers. 39 | * 40 | * @return array<string,mixed>|false Filtered update data. 41 | */ 42 | function filter_update_plugins( $update, $plugin_data ) { 43 | // @phpstan-ignore requireOnce.fileNotFound 44 | require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; 45 | $updater = new \WP_Automatic_Updater(); 46 | 47 | if ( $updater->is_vcs_checkout( dirname( __DIR__ ) ) ) { 48 | return $update; 49 | } 50 | 51 | $response = wp_remote_get( $plugin_data['UpdateURI'] ); 52 | $response = wp_remote_retrieve_body( $response ); 53 | 54 | if ( '' === $response ) { 55 | return $update; 56 | } 57 | 58 | /** 59 | * Encoded update data. 60 | * 61 | * @var array<string,mixed> $result 62 | */ 63 | $result = json_decode( $response, true ); 64 | 65 | return $result; 66 | } 67 | 68 | /** 69 | * Plugin activation hook. 70 | * 71 | * @codeCoverageIgnore 72 | * 73 | * @return void 74 | */ 75 | function activate_plugin(): void { 76 | register_session_post_type(); 77 | 78 | if ( false === wp_next_scheduled( 'mcp_sessions_cleanup' ) ) { 79 | wp_schedule_event( time(), 'hourly', 'mcp_sessions_cleanup' ); 80 | } 81 | } 82 | 83 | /** 84 | * Plugin deactivation hook. 85 | * 86 | * @codeCoverageIgnore 87 | * 88 | * @return void 89 | */ 90 | function deactivate_plugin(): void { 91 | unregister_post_type( 'mcp_session' ); 92 | 93 | $timestamp = wp_next_scheduled( 'mcp_sessions_cleanup' ); 94 | if ( false !== $timestamp ) { 95 | wp_unschedule_event( $timestamp, 'mcp_sessions_cleanup' ); 96 | } 97 | } 98 | 99 | /** 100 | * Registers a new post type for MCP sessions. 101 | * 102 | * @return void 103 | */ 104 | function register_session_post_type(): void { 105 | register_post_type( 106 | 'mcp_session', 107 | [ 108 | 'label' => __( 'MCP Sessions', 'mcp' ), 109 | 'public' => false, 110 | // @phpstan-ignore cast.useless 111 | 'show_ui' => defined( 'WP_DEBUG' ) && (bool) WP_DEBUG, // For debugging. 112 | ] 113 | ); 114 | } 115 | 116 | /** 117 | * Registers the MCP server REST API routes. 118 | * 119 | * @return void 120 | */ 121 | function register_rest_routes(): void { 122 | $controller = new RestController(); 123 | $controller->register_routes(); 124 | } 125 | 126 | /** 127 | * Delete unresolved upload requests that are older than 1 day. 128 | * 129 | * @return void 130 | */ 131 | function delete_old_sessions(): void { 132 | $args = [ 133 | 'post_type' => 'mcp_session', 134 | 'post_status' => 'publish', 135 | 'numberposts' => -1, 136 | 'date_query' => [ 137 | [ 138 | 'before' => '1 day ago', 139 | 'inclusive' => true, 140 | ], 141 | ], 142 | 'suppress_filters' => false, 143 | ]; 144 | 145 | $posts = get_posts( $args ); 146 | 147 | foreach ( $posts as $post ) { 148 | wp_delete_post( $post->ID, true ); 149 | } 150 | } 151 | 152 | 153 | /** 154 | * Validates the application password credentials passed via `Authorization` header. 155 | * 156 | * @param int|false $input_user User ID if one has been determined, false otherwise. 157 | * @return int|false The authenticated user ID if successful, false otherwise. 158 | */ 159 | function validate_bearer_token( $input_user ) { 160 | // Don't authenticate twice. 161 | if ( ! empty( $input_user ) ) { 162 | return $input_user; 163 | } 164 | 165 | if ( ! wp_is_application_passwords_available() ) { 166 | return $input_user; 167 | } 168 | 169 | if ( ! isset( $_SERVER['HTTP_AUTHORIZATION'] ) || ! is_string( $_SERVER['HTTP_AUTHORIZATION'] ) ) { 170 | return $input_user; 171 | } 172 | 173 | $matches = []; 174 | $match = preg_match( '/^Bearer (?<user>.*):(?<password>.*)$/', $_SERVER['HTTP_AUTHORIZATION'], $matches ); 175 | 176 | if ( 1 !== $match ) { 177 | return $input_user; 178 | } 179 | 180 | $authenticated = wp_authenticate_application_password( null, $matches['user'], $matches['password'] ); 181 | 182 | if ( $authenticated instanceof WP_User ) { 183 | return $authenticated->ID; 184 | } 185 | 186 | // If it wasn't a user what got returned, just pass on what we had received originally. 187 | return $input_user; 188 | } 189 | ``` -------------------------------------------------------------------------------- /src/MCP/Servers/WordPress/Tools/RestApi.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace McpWp\MCP\Servers\WordPress\Tools; 4 | 5 | use McpWp\MCP\Server; 6 | use Psr\Log\LoggerInterface; 7 | use Psr\Log\NullLogger; 8 | use WP_REST_Request; 9 | use WP_REST_Response; 10 | 11 | /** 12 | * REST API tools class. 13 | * 14 | * @phpstan-import-type ToolDefinition from Server 15 | * @phpstan-import-type ToolInputSchema from Server 16 | * @phpstan-type ArgumentSchema array{description?: string, type?: string, required?: bool} 17 | */ 18 | readonly class RestApi { 19 | private LoggerInterface $logger; 20 | 21 | /** 22 | * Constructor. 23 | * 24 | * @param LoggerInterface|null $logger Logger. 25 | */ 26 | public function __construct( ?LoggerInterface $logger = null ) { 27 | $this->logger = $logger ?? new NullLogger(); 28 | } 29 | 30 | /** 31 | * Returns a list of tools for all REST API routes. 32 | * 33 | * @throws \Exception 34 | * 35 | * @return array<int, ToolDefinition> Tools. 36 | */ 37 | public function get_tools(): array { 38 | $server = rest_get_server(); 39 | $routes = $server->get_routes(); 40 | $namespaces = $server->get_namespaces(); 41 | $tools = []; 42 | 43 | foreach ( $routes as $route => $handlers ) { 44 | // Do not include namespace routes in the response. 45 | if ( in_array( ltrim( $route, '/' ), $namespaces, true ) ) { 46 | continue; 47 | } 48 | 49 | /** 50 | * @param array{methods: array<string, mixed>, accept_json: bool, accept_raw: bool, show_in_index: bool, args: array, callback: array, permission_callback?: array} $handler 51 | */ 52 | foreach ( $handlers as $handler ) { 53 | 54 | /** 55 | * Methods for this route, e.g. 'GET', 'POST', 'PUT', etc. 56 | * 57 | * @var string[] $methods 58 | */ 59 | $methods = array_keys( $handler['methods'] ); 60 | 61 | // If WP_REST_Server::EDITABLE is used, keeo only POST but not PUT or PATCH. 62 | if ( in_array( 'POST', $methods, true ) ) { 63 | $methods = array_diff( $methods, [ 'PUT', 'PATCH' ] ); 64 | } 65 | 66 | foreach ( $methods as $method ) { 67 | $title = ''; 68 | 69 | if ( 70 | in_array( 71 | "$method $route", 72 | [ 73 | 'GET/', 74 | 'POST /batch/v1', 75 | ], 76 | true 77 | ) 78 | ) { 79 | continue; 80 | } 81 | 82 | if ( 83 | is_array( $handler['callback'] ) && 84 | isset( $handler['callback'][0] ) && 85 | $handler['callback'][0] instanceof \WP_REST_Controller 86 | ) { 87 | $controller = $handler['callback'][0]; 88 | $schema = $controller->get_public_item_schema(); 89 | if ( isset( $schema['title'] ) ) { 90 | $title = $schema['title']; 91 | } 92 | } 93 | 94 | if ( isset( $handler['permission_callback'] ) && is_callable( $handler['permission_callback'] ) ) { 95 | $has_required_parameter = (bool) preg_match_all( '/\(?P<(\w+)>/', $route ); 96 | 97 | if ( ! $has_required_parameter ) { 98 | /** 99 | * Permission callback result. 100 | * 101 | * @var bool|\WP_Error $result 102 | */ 103 | $result = call_user_func( $handler['permission_callback'], new WP_REST_Request() ); 104 | if ( true !== $result ) { 105 | continue; 106 | } 107 | } 108 | } 109 | 110 | $information = new RouteInformation( 111 | $route, 112 | $method, 113 | $title, 114 | ); 115 | 116 | // Autosaves or revisions controller could come up multiple times, skip in that case. 117 | if ( array_key_exists( $information->get_name(), $tools ) ) { 118 | continue; 119 | } 120 | 121 | $tool = [ 122 | 'name' => $information->get_name(), 123 | 'description' => $information->get_description(), 124 | 'inputSchema' => $this->args_to_schema( $handler['args'] ), 125 | 'annotations' => [ 126 | // A human-readable title for the tool. 127 | 'title' => null, // TODO: Add titles. 128 | // If true, the tool does not modify its environment. 129 | 'readOnlyHint' => 'GET' === $method, 130 | // This property is meaningful only when `readOnlyHint == false` 131 | 'idempotentHint' => 'GET' === $method, 132 | // Whether the tool may perform destructive updates to its environment. 133 | // This property is meaningful only when `readOnlyHint == false` 134 | 'destructiveHint' => 'DELETE' === $method, 135 | ], 136 | 'callback' => function ( $params ) use ( $route, $method ) { 137 | return json_encode( $this->rest_callable( $route, $method, $params ), JSON_THROW_ON_ERROR ); 138 | }, 139 | ]; 140 | 141 | $tools[ $information->get_name() ] = $tool; 142 | } 143 | } 144 | } 145 | 146 | return array_values( $tools ); 147 | } 148 | 149 | /** 150 | * REST route tool callback. 151 | * 152 | * @throws \JsonException 153 | * 154 | * @param string $route Route 155 | * @param string $method HTTP method. 156 | * @param array<string, mixed> $params Route params. 157 | * @return array<string, mixed> REST response data. 158 | */ 159 | private function rest_callable( string $route, string $method, array $params ): array { 160 | $server = rest_get_server(); 161 | 162 | preg_match_all( '/\(?P<(\w+)>/', $route, $matches ); 163 | 164 | foreach ( $matches[1] as $match ) { 165 | if ( array_key_exists( $match, $params ) ) { 166 | $route = (string) preg_replace( 167 | '/(\(\?P<' . $match . '>.*?\))/', 168 | // @phpstan-ignore cast.string 169 | (string) $params[ $match ], 170 | $route, 171 | 1 172 | ); 173 | unset( $params[ $match ] ); 174 | } 175 | } 176 | 177 | // Fix incorrect meta inputs. 178 | if ( isset( $params['meta'] ) ) { 179 | if ( false === $params['meta'] || '' === $params['meta'] || [] === $params['meta'] ) { 180 | unset( $params['meta'] ); 181 | } 182 | } 183 | 184 | $this->logger->debug( "$method $route with input: " . json_encode( $params, JSON_THROW_ON_ERROR ) ); 185 | 186 | $request = new WP_REST_Request( $method, $route ); 187 | $request->set_body_params( $params ); 188 | 189 | /** 190 | * REST API response. 191 | * 192 | * @var WP_REST_Response $response 193 | */ 194 | $response = $server->dispatch( $request ); 195 | 196 | /** 197 | * Response data. 198 | * 199 | * @phpstan-var array<string, mixed> $data 200 | */ 201 | $data = $server->response_to_data( $response, false ); // @phpstan-ignore varTag.type 202 | 203 | // Reduce amount of data that is returned. 204 | unset( $data['_links'], $data['_embedded'] ); 205 | 206 | foreach ( $data as &$item ) { 207 | if ( is_array( $item ) ) { 208 | unset( $item['_links'], $item['_embedded'] ); 209 | } 210 | } 211 | 212 | return $data; 213 | } 214 | 215 | /** 216 | * @throws \Exception 217 | * 218 | * @param array<string, mixed> $args REST API route arguments. 219 | * @return array<string, mixed> Normalized schema. 220 | * 221 | * @phpstan-param array<string, ArgumentSchema> $args REST API route arguments. 222 | * @phpstan-return ToolInputSchema 223 | */ 224 | private function args_to_schema( array $args = [] ): array { 225 | $schema = []; 226 | $required = []; 227 | 228 | if ( empty( $args ) ) { 229 | return [ 230 | 'type' => 'object', 231 | 'properties' => [], 232 | 'required' => [], 233 | ]; 234 | } 235 | 236 | foreach ( $args as $title => $arg ) { 237 | $description = $arg['description'] ?? $title; 238 | $type = $this->sanitize_type( $arg['type'] ?? 'string' ); 239 | 240 | $schema[ $title ] = [ 241 | 'type' => $type, 242 | 'description' => $description, 243 | ]; 244 | if ( isset( $arg['required'] ) && true === $arg['required'] ) { 245 | $required[] = $title; 246 | } 247 | } 248 | 249 | return [ 250 | 'type' => 'object', 251 | 'properties' => $schema, 252 | 'required' => $required, 253 | ]; 254 | } 255 | 256 | /** 257 | * Normalize a type from REST API schema to MCP PHP SDK JSON schema. 258 | * 259 | * @param mixed $type Type. 260 | * @return string Normalized type. 261 | * @throws \Exception 262 | */ 263 | private function sanitize_type( $type ): string { 264 | 265 | $mapping = array( 266 | 'string' => 'string', 267 | 'integer' => 'integer', 268 | 'int' => 'integer', 269 | 'number' => 'integer', 270 | 'boolean' => 'boolean', 271 | 'bool' => 'boolean', 272 | ); 273 | 274 | // Validated types: 275 | if ( ! \is_array( $type ) && isset( $mapping[ $type ] ) ) { 276 | return $mapping[ $type ]; 277 | } 278 | 279 | if ( 'array' === $type || 'object' === $type ) { 280 | return 'string'; // TODO, better solution. 281 | } 282 | if ( empty( $type ) || 'null' === $type ) { 283 | return 'string'; 284 | } 285 | 286 | if ( ! \is_array( $type ) ) { 287 | // @phpstan-ignore binaryOp.invalid 288 | throw new \Exception( 'Invalid type: ' . $type ); 289 | } 290 | 291 | // Find valid values in array. 292 | if ( \in_array( 'string', $type, true ) ) { 293 | return 'string'; 294 | } 295 | if ( \in_array( 'integer', $type, true ) ) { 296 | return 'integer'; 297 | } 298 | // TODO, better types handling. 299 | return 'string'; 300 | } 301 | } 302 | ``` -------------------------------------------------------------------------------- /src/MCP/Server.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace McpWp\MCP; 4 | 5 | use InvalidArgumentException; 6 | use Mcp\Server\NotificationOptions; 7 | use Mcp\Server\Server as McpServer; 8 | use Mcp\Shared\ErrorData; 9 | use Mcp\Shared\McpError; 10 | use Mcp\Shared\Version; 11 | use Mcp\Types\CallToolResult; 12 | use Mcp\Types\Implementation; 13 | use Mcp\Types\InitializeResult; 14 | use Mcp\Types\JSONRPCError; 15 | use Mcp\Types\JsonRpcErrorObject; 16 | use Mcp\Types\JsonRpcMessage; 17 | use Mcp\Types\JSONRPCNotification; 18 | use Mcp\Types\JSONRPCRequest; 19 | use Mcp\Types\JSONRPCResponse; 20 | use Mcp\Types\ListResourcesResult; 21 | use Mcp\Types\ListResourceTemplatesResult; 22 | use Mcp\Types\ListToolsResult; 23 | use Mcp\Types\ReadResourceResult; 24 | use Mcp\Types\RequestId; 25 | use Mcp\Types\RequestParams; 26 | use Mcp\Types\Resource; 27 | use Mcp\Types\ResourceTemplate; 28 | use Mcp\Types\Result; 29 | use Mcp\Types\TextContent; 30 | use Mcp\Types\TextResourceContents; 31 | use Mcp\Types\Tool; 32 | use Psr\Log\LoggerInterface; 33 | use Psr\Log\NullLogger; 34 | 35 | /** 36 | * @phpstan-type ToolInputSchema array{type: string, properties: array<string, mixed>, required: string[]} 37 | * @phpstan-type ToolDefinition array{name: string, description?: string, callback: callable, inputSchema: ToolInputSchema} 38 | */ 39 | class Server { 40 | /** 41 | * @var array<string, array{tool: Tool, callback: callable}> 42 | */ 43 | private array $tools = []; 44 | 45 | /** 46 | * @var Array<Resource> 47 | */ 48 | private array $resources = []; 49 | 50 | /** 51 | * @var Array<ResourceTemplate> 52 | */ 53 | private array $resource_templates = []; 54 | 55 | protected McpServer $mcp_server; 56 | 57 | protected LoggerInterface $logger; 58 | 59 | public function __construct( private readonly string $name, ?LoggerInterface $logger = null ) { 60 | $this->logger = $logger ?? new NullLogger(); 61 | 62 | $this->mcp_server = new McpServer( $name, $this->logger ); 63 | 64 | $this->mcp_server->registerHandler( 65 | 'initialize', 66 | [ $this, 'initialize' ] 67 | ); 68 | 69 | $this->mcp_server->registerHandler( 70 | 'tools/list', 71 | [ $this, 'list_tools' ] 72 | ); 73 | 74 | $this->mcp_server->registerHandler( 75 | 'tools/call', 76 | [ $this, 'call_tool' ] 77 | ); 78 | 79 | $this->mcp_server->registerHandler( 80 | 'resources/list', 81 | [ $this, 'list_resources' ] 82 | ); 83 | 84 | $this->mcp_server->registerHandler( 85 | 'resources/read', 86 | [ $this, 'read_resources' ] 87 | ); 88 | 89 | $this->mcp_server->registerHandler( 90 | 'resources/templates/list', 91 | [ $this, 'list_resource_templates' ] 92 | ); 93 | 94 | $this->mcp_server->registerNotificationHandler( 95 | 'notifications/initialized', 96 | [ $this, 'do_nothing' ] 97 | ); 98 | } 99 | 100 | public function do_nothing(): void { 101 | // Do nothing. 102 | } 103 | 104 | /** 105 | * Registers a new MCP tool. 106 | * 107 | * @param ToolDefinition $tool_definition Tool definition. 108 | * @return void 109 | */ 110 | public function register_tool( array $tool_definition ): void { 111 | $name = $tool_definition['name']; 112 | $callable = $tool_definition['callback']; 113 | 114 | if ( strlen( $name ) > 64 ) { 115 | if ( 1 !== preg_match( '/^[a-zA-Z0-9_-]{1,64}$/', $name ) ) { 116 | throw new InvalidArgumentException( "Tool names should match pattern '^[a-zA-Z0-9_-]{1,64}$'. Received: '$name'." ); 117 | } 118 | } 119 | 120 | if ( array_key_exists( $name, $this->tools ) ) { 121 | throw new InvalidArgumentException( "Tool $name is already registered" ); 122 | } 123 | 124 | foreach ( $tool_definition['inputSchema']['properties'] as $property => $schema ) { 125 | // Anthropic has strict requirements for property keys. 126 | if ( 1 !== preg_match( '/^[a-zA-Z0-9_-]{1,64}$/', $property ) ) { 127 | throw new InvalidArgumentException( "Property keys should match pattern '^[a-zA-Z0-9_-]{1,64}$'. Received: '$property' (tool: $name)." ); 128 | } 129 | } 130 | 131 | $this->tools[ $name ] = [ 132 | 'tool' => Tool::fromArray( $tool_definition ), 133 | 'callback' => $callable, 134 | ]; 135 | } 136 | 137 | public function initialize(): InitializeResult { 138 | return new InitializeResult( 139 | capabilities: $this->mcp_server->getCapabilities( new NotificationOptions(), [] ), 140 | serverInfo: new Implementation( 141 | $this->name, 142 | '0.0.1', // TODO: Make dynamic. 143 | ), 144 | protocolVersion: Version::LATEST_PROTOCOL_VERSION 145 | ); 146 | } 147 | 148 | // TODO: Implement pagination, see https://spec.modelcontextprotocol.io/specification/2024-11-05/server/utilities/pagination/#response-format 149 | // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found 150 | public function list_tools( RequestParams $params ): ListToolsResult { 151 | $prepared_tools = []; 152 | foreach ( $this->tools as $tool ) { 153 | $prepared_tools[] = $tool['tool']; 154 | } 155 | 156 | return new ListToolsResult( $prepared_tools ); 157 | } 158 | 159 | public function call_tool( RequestParams $params ): CallToolResult { 160 | $found_tool = null; 161 | foreach ( $this->tools as $name => $tool ) { 162 | // @phpstan-ignore property.notFound 163 | if ( $name === $params->name ) { 164 | $found_tool = $tool; 165 | break; 166 | } 167 | } 168 | 169 | if ( null === $found_tool ) { 170 | // @phpstan-ignore property.notFound 171 | throw new InvalidArgumentException( "Unknown tool: {$params->name}" ); 172 | } 173 | 174 | // @phpstan-ignore property.notFound 175 | $result = call_user_func( $found_tool['callback'], $params->arguments ); 176 | 177 | if ( $result instanceof CallToolResult ) { 178 | return $result; 179 | } 180 | 181 | if ( is_wp_error( $result ) ) { 182 | return new CallToolResult( 183 | [ 184 | new TextContent( 185 | $result->get_error_message() 186 | ), 187 | ], 188 | true 189 | ); 190 | } 191 | 192 | if ( is_string( $result ) ) { 193 | $result = [ new TextContent( $result ) ]; 194 | } 195 | 196 | if ( ! is_array( $result ) ) { 197 | $result = [ $result ]; 198 | } 199 | return new CallToolResult( $result ); 200 | } 201 | 202 | // TODO: Implement pagination, see https://spec.modelcontextprotocol.io/specification/2024-11-05/server/utilities/pagination/#response-format 203 | // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found 204 | public function list_resources(): ListResourcesResult { 205 | return new ListResourcesResult( $this->resources ); 206 | } 207 | 208 | // TODO: Make dynamic. 209 | public function read_resources( RequestParams $params ): ReadResourceResult { 210 | // @phpstan-ignore property.notFound 211 | $uri = $params->uri; 212 | if ( 'example://greeting' !== $uri ) { 213 | throw new InvalidArgumentException( "Unknown resource: {$uri}" ); 214 | } 215 | 216 | return new ReadResourceResult( 217 | [ 218 | new TextResourceContents( 219 | 'Hello from the example MCP server!', 220 | $uri, 221 | 'text/plain' 222 | ), 223 | ] 224 | ); 225 | } 226 | 227 | /** 228 | * Registers a single resource. 229 | * 230 | * @param Resource $res Resource 231 | * @return void 232 | */ 233 | public function register_resource( Resource $res ): void { 234 | $this->resources[ $res->name ] = $res; 235 | } 236 | 237 | // TODO: Implement pagination, see https://spec.modelcontextprotocol.io/specification/2024-11-05/server/utilities/pagination/#response-format 238 | // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found 239 | public function list_resource_templates( RequestParams $params ): ListResourceTemplatesResult { 240 | return new ListResourceTemplatesResult( $this->resource_templates ); 241 | } 242 | 243 | /** 244 | * Registers a resource template. 245 | * 246 | * @param ResourceTemplate $resource_template Resource template. 247 | * @return void 248 | */ 249 | public function register_resource_template( ResourceTemplate $resource_template ): void { 250 | $this->resource_templates[ $resource_template->name ] = $resource_template; 251 | } 252 | 253 | /** 254 | * Processes an incoming message from the client. 255 | * 256 | * @param JsonRpcMessage $message 257 | * @return JsonRpcMessage|void|null 258 | */ 259 | public function handle_message( JsonRpcMessage $message ) { 260 | $this->logger->debug( 'Received message: ' . json_encode( $message ) ); 261 | 262 | $inner_message = $message->message; 263 | 264 | try { 265 | if ( $inner_message instanceof JSONRPCRequest ) { 266 | // It's a request 267 | return $this->process_request( $inner_message ); 268 | } 269 | 270 | if ( $inner_message instanceof JSONRPCNotification ) { 271 | // It's a notification 272 | $this->process_notification( $inner_message ); 273 | return null; 274 | } 275 | 276 | // Server does not expect responses from client; ignore or log 277 | $this->logger->warning( 'Received unexpected message type: ' . get_class( $inner_message ) ); 278 | } catch ( McpError $e ) { 279 | if ( $inner_message instanceof JSONRPCRequest ) { 280 | return $this->send_error( $inner_message->id, $e->error ); 281 | } 282 | } catch ( \Exception $e ) { 283 | $this->logger->error( 'Error handling message: ' . $e->getMessage() ); 284 | if ( $inner_message instanceof JSONRPCRequest ) { 285 | // Code -32603 is Internal error as per JSON-RPC spec 286 | return $this->send_error( 287 | $inner_message->id, 288 | new ErrorData( 289 | -32603, 290 | $e->getMessage() 291 | ) 292 | ); 293 | } 294 | } 295 | } 296 | 297 | /** 298 | * Processes a JSONRPCRequest message. 299 | */ 300 | private function process_request( JSONRPCRequest $request ): JsonRpcMessage { 301 | $method = $request->method; 302 | $handlers = $this->mcp_server->getHandlers(); 303 | $handler = $handlers[ $method ] ?? null; 304 | 305 | if ( null === $handler ) { 306 | throw new McpError( 307 | new ErrorData( 308 | -32601, // Method not found 309 | "Method not found: {$method}" 310 | ) 311 | ); 312 | } 313 | 314 | $params = $request->params ?? null; 315 | $result = $handler( $params ); 316 | 317 | if ( ! $result instanceof Result ) { 318 | $result = new Result(); 319 | } 320 | 321 | return $this->send_response( $request->id, $result ); 322 | } 323 | 324 | /** 325 | * Processes a JSONRPCNotification message. 326 | */ 327 | private function process_notification( JSONRPCNotification $notification ): void { 328 | $method = $notification->method; 329 | $handlers = $this->mcp_server->getNotificationHandlers(); 330 | $handler = $handlers[ $method ] ?? null; 331 | 332 | if ( null !== $handler ) { 333 | $params = $notification->params ?? null; 334 | $handler( $params ); 335 | } 336 | 337 | $this->logger->warning( "No handler registered for notification method: $method" ); 338 | } 339 | 340 | /** 341 | * Sends a response to a request. 342 | * 343 | * @param RequestId $id The request ID to respond to. 344 | * @param Result $result The result object. 345 | */ 346 | private function send_response( RequestId $id, Result $result ): JsonRpcMessage { 347 | // Create a JSONRPCResponse object and wrap in JsonRpcMessage 348 | $response = new JSONRPCResponse( 349 | '2.0', 350 | $id, 351 | $result 352 | ); 353 | $response->validate(); 354 | 355 | return new JsonRpcMessage( $response ); 356 | } 357 | 358 | 359 | /** 360 | * Sends an error response to a request. 361 | * 362 | * @param RequestId $id The request ID to respond to. 363 | * @param ErrorData $error The error data. 364 | */ 365 | private function send_error( RequestId $id, ErrorData $error ): JsonRpcMessage { 366 | $error_object = new JsonRpcErrorObject( 367 | $error->code, 368 | $error->message, 369 | $error->data ?? null 370 | ); 371 | 372 | $response = new JSONRPCError( 373 | '2.0', 374 | $id, 375 | $error_object 376 | ); 377 | $response->validate(); 378 | 379 | return new JsonRpcMessage( $response ); 380 | } 381 | } 382 | ``` -------------------------------------------------------------------------------- /src/RestController.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | /** 3 | * Main REST API controller. 4 | * 5 | * @package McpWp 6 | */ 7 | 8 | declare(strict_types = 1); 9 | 10 | namespace McpWp; 11 | 12 | use Mcp\Types\InitializeResult; 13 | use Mcp\Types\JSONRPCError; 14 | use Mcp\Types\JsonRpcErrorObject; 15 | use Mcp\Types\JsonRpcMessage; 16 | use Mcp\Types\JSONRPCNotification; 17 | use Mcp\Types\JSONRPCRequest; 18 | use Mcp\Types\JSONRPCResponse; 19 | use Mcp\Types\NotificationParams; 20 | use Mcp\Types\RequestId; 21 | use Mcp\Types\RequestParams; 22 | use McpWp\MCP\Servers\WordPress\WordPress; 23 | use WP_Error; 24 | use WP_Http; 25 | use WP_Post; 26 | use WP_REST_Controller; 27 | use WP_REST_Request; 28 | use WP_REST_Response; 29 | use WP_REST_Server; 30 | 31 | /** 32 | * MCP REST API controller. 33 | */ 34 | class RestController extends WP_REST_Controller { 35 | /** 36 | * MCP session ID header name. 37 | */ 38 | protected const SESSION_ID_HEADER = 'Mcp-Session-Id'; 39 | 40 | /** 41 | * The namespace of this controller's route. 42 | * 43 | * @var string 44 | */ 45 | protected $namespace = 'mcp/v1'; 46 | 47 | /** 48 | * Registers the routes for the objects of the controller. 49 | * 50 | * @see register_rest_route() 51 | */ 52 | public function register_routes(): void { 53 | register_rest_route( 54 | $this->namespace, 55 | '/mcp', 56 | [ 57 | [ 58 | 'methods' => WP_REST_Server::CREATABLE, 59 | 'callback' => [ $this, 'create_item' ], 60 | 'permission_callback' => [ $this, 'create_item_permissions_check' ], 61 | 'args' => [ 62 | 'jsonrpc' => [ 63 | 'type' => 'string', 64 | 'enum' => [ '2.0' ], 65 | 'description' => __( 'JSON-RPC protocol version.', 'mcp' ), 66 | 'required' => true, 67 | ], 68 | 'id' => [ 69 | 'type' => [ 'string', 'integer' ], 70 | 'description' => __( 'Identifier established by the client.', 'mcp' ), 71 | // It should be required, but it's not sent for things like notifications. 72 | 'required' => false, 73 | ], 74 | 'method' => [ 75 | 'type' => 'string', 76 | 'description' => __( 'Method to be invoked.', 'mcp' ), 77 | 'required' => true, 78 | ], 79 | 'params' => [ 80 | 'type' => 'object', 81 | 'description' => __( 'Method to be invoked.', 'mcp' ), 82 | ], 83 | ], 84 | ], 85 | [ 86 | 'methods' => WP_REST_Server::DELETABLE, 87 | 'callback' => [ $this, 'delete_item' ], 88 | 'permission_callback' => [ $this, 'delete_item_permissions_check' ], 89 | ], 90 | [ 91 | 'methods' => WP_REST_Server::READABLE, 92 | 'callback' => [ $this, 'get_item' ], 93 | 'permission_callback' => [ $this, 'get_item_permissions_check' ], 94 | ], 95 | 'schema' => [ $this, 'get_public_item_schema' ], 96 | ] 97 | ); 98 | } 99 | 100 | /** 101 | * Checks if a given request has access to create items. 102 | * 103 | * @phpstan-param WP_REST_Request<array{jsonrpc: string, id?: string|number, method: string, params: array<string, mixed>}> $request 104 | * 105 | * @param WP_REST_Request $request Full details about the request. 106 | * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise. 107 | */ 108 | public function create_item_permissions_check( $request ): true|WP_Error { 109 | if ( ! is_user_logged_in() ) { 110 | return new WP_Error( 111 | 'rest_not_logged_in', 112 | __( 'You are not currently logged in.', 'mcp' ), 113 | array( 'status' => WP_Http::UNAUTHORIZED ) 114 | ); 115 | } 116 | 117 | if ( 'initialize' !== $request['method'] ) { 118 | return $this->check_session( $request ); 119 | } 120 | 121 | return true; 122 | } 123 | 124 | /** 125 | * Creates one item from the collection. 126 | * 127 | * @todo Support batch requests 128 | * 129 | * @phpstan-param WP_REST_Request<array{jsonrpc: string, id?: string|number, method?: string, params: array<string, mixed>, result?: array<string, mixed>, error?: array{code: int, message: string, data: mixed}}> $request 130 | * 131 | * @param WP_REST_Request $request Full details about the request. 132 | * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. 133 | */ 134 | public function create_item( $request ): WP_Error|WP_REST_Response { 135 | $message = new JsonRpcMessage( 136 | new JSONRPCError( 137 | '2.0', 138 | new RequestId( '0' ), 139 | new JsonRpcErrorObject( 140 | -32600, 141 | 'Invalid JSON-RPC message structure.', 142 | null 143 | ) 144 | ) 145 | ); 146 | 147 | if ( isset( $request['method'] ) ) { 148 | // It's a Request or Notification 149 | if ( isset( $request['id'] ) ) { 150 | $params = new RequestParams(); 151 | 152 | if ( isset( $request['params'] ) ) { 153 | foreach ( $request['params'] as $key => $value ) { 154 | // @phpstan-ignore property.dynamicName 155 | $params->{$key} = $value; 156 | } 157 | } 158 | 159 | $message = new JsonRpcMessage( 160 | new JSONRPCRequest( 161 | '2.0', 162 | new RequestId( (string) $request['id'] ), 163 | isset( $request['params'] ) ? $params : null, 164 | $request['method'], 165 | ) 166 | ); 167 | } else { 168 | $params = new NotificationParams(); 169 | 170 | if ( isset( $request['params'] ) ) { 171 | foreach ( $request['params'] as $key => $value ) { 172 | // @phpstan-ignore property.dynamicName 173 | $params->{$key} = $value; 174 | } 175 | } 176 | $message = new JsonRpcMessage( 177 | new JSONRPCNotification( 178 | '2.0', 179 | isset( $request['params'] ) ? $params : null, 180 | $request['method'], 181 | ) 182 | ); 183 | } 184 | } elseif ( isset( $request['result'] ) || isset( $request['error'] ) ) { 185 | // TODO: Can the client actually send errors? 186 | // TODO: Can the client actually send results? 187 | // It's a Response or Error 188 | if ( isset( $request['error'] ) ) { 189 | // It's an Error 190 | $error_data = $request['error']; 191 | $message = new JsonRpcMessage( 192 | new JSONRPCError( 193 | '2.0', 194 | new RequestId( (string) ( $request['id'] ?? 0 ) ), 195 | new JsonRpcErrorObject( 196 | $error_data['code'], 197 | $error_data['message'], 198 | $error_data['data'] ?? null 199 | ) 200 | ) 201 | ); 202 | } else { 203 | // It's a Response 204 | $message = new JsonRpcMessage( 205 | new JSONRPCResponse( 206 | '2.0', 207 | new RequestId( (string) ( $request['id'] ?? 0 ) ), 208 | $request['result'] 209 | ) 210 | ); 211 | } 212 | } 213 | 214 | $server = new WordPress(); 215 | $mcp_response = $server->handle_message( $message ); 216 | $response = new WP_REST_Response(); 217 | 218 | if ( null !== $mcp_response ) { 219 | $response->set_data( $mcp_response ); 220 | } else { 221 | $response->set_status( 202 ); 222 | } 223 | 224 | // @phpstan-ignore property.notFound 225 | if ( isset( $mcp_response ) && $mcp_response->message->result instanceof InitializeResult ) { 226 | $uuid = wp_generate_uuid4(); 227 | 228 | wp_insert_post( 229 | [ 230 | 'post_type' => 'mcp_session', 231 | 'post_status' => 'publish', 232 | 'post_title' => $uuid, 233 | 'post_name' => $uuid, 234 | ] 235 | ); 236 | 237 | $response->header( self::SESSION_ID_HEADER, $uuid ); 238 | } 239 | 240 | // Quick workaround for MCP Inspector. 241 | $response->header( 'Access-Control-Allow-Origin', '*' ); 242 | 243 | // TODO: send right status code. 244 | 245 | return $response; 246 | } 247 | 248 | /** 249 | * Checks if a given request has access to terminate an MCP session. 250 | * 251 | * @phpstan-param WP_REST_Request<array{jsonrpc: string, id?: string|number, method: string, params: array<string, mixed>}> $request 252 | * 253 | * @param WP_REST_Request $request Full details about the request. 254 | * @return true|WP_Error True if the request has access to delete the item, WP_Error object otherwise. 255 | */ 256 | public function delete_item_permissions_check( $request ): true|WP_Error { 257 | if ( ! is_user_logged_in() ) { 258 | return new WP_Error( 259 | 'rest_not_logged_in', 260 | __( 'You are not currently logged in.', 'mcp' ), 261 | array( 'status' => WP_Http::UNAUTHORIZED ) 262 | ); 263 | } 264 | 265 | return $this->check_session( $request ); 266 | } 267 | 268 | /** 269 | * Terminates an MCP session. 270 | * 271 | * @phpstan-param WP_REST_Request<array{jsonrpc: string, id?: string|number, method: string, params: array<string, mixed>}> $request 272 | * 273 | * @param WP_REST_Request $request Full details about the request. 274 | * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. 275 | */ 276 | public function delete_item( $request ): WP_Error|WP_REST_Response { 277 | /** 278 | * Session post object. 279 | * 280 | * @var WP_Post $session 281 | */ 282 | $session = $this->get_session( (string) $request->get_header( self::SESSION_ID_HEADER ) ); 283 | 284 | wp_delete_post( $session->ID, true ); 285 | 286 | return new WP_REST_Response( '' ); 287 | } 288 | 289 | 290 | /** 291 | * Checks if a given request has access to get a specific item. 292 | * 293 | * @phpstan-param WP_REST_Request<array{jsonrpc: string, id?: string|number, method: string, params: array<string, mixed>}> $request 294 | * 295 | * @param WP_REST_Request $request Full details about the request. 296 | * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise. 297 | */ 298 | public function get_item_permissions_check( $request ): true|WP_Error { 299 | if ( ! is_user_logged_in() ) { 300 | return new WP_Error( 301 | 'rest_not_logged_in', 302 | __( 'You are not currently logged in.', 'mcp' ), 303 | array( 'status' => WP_Http::UNAUTHORIZED ) 304 | ); 305 | } 306 | 307 | $session = $this->check_session( $request ); 308 | 309 | if ( is_wp_error( $session ) ) { 310 | return $session; 311 | } 312 | 313 | return new WP_Error( 314 | 'mcp_sse_not_supported', 315 | __( 'Server does not currently offer an SSE stream.', 'mcp' ), 316 | array( 'status' => WP_Http::METHOD_NOT_ALLOWED ) 317 | ); 318 | } 319 | 320 | /** 321 | * Retrieves the post's schema, conforming to JSON Schema. 322 | * 323 | * @return array<string, mixed> Item schema data. 324 | */ 325 | public function get_item_schema() { 326 | if ( null !== $this->schema ) { 327 | return $this->add_additional_fields_schema( $this->schema ); 328 | } 329 | 330 | $schema = [ 331 | '$schema' => 'http://json-schema.org/draft-04/schema#', 332 | 'title' => __( 'MCP Server', 'mcp' ), 333 | 'type' => 'object', 334 | // Base properties for every Post. 335 | 'properties' => [ 336 | 'jsonrpc' => [ 337 | 'description' => __( 'JSON-RPC protocol version.', 'mcp' ), 338 | 'type' => 'string', 339 | 'context' => [ 'view' ], 340 | ], 341 | 'id' => [ 342 | 'description' => __( 'Identifier established by the client.', 'mcp' ), 343 | 'type' => [ 'string', 'integer' ], 344 | 'context' => [ 'view' ], 345 | ], 346 | 'result' => [ 347 | 'description' => __( 'Result', 'mcp' ), 348 | 'type' => [ 'object' ], 349 | 'context' => [ 'view' ], 350 | ], 351 | 'date_gmt' => [ 352 | 'description' => __( 'The date the post was published, as GMT.' ), 353 | 'type' => [ 'string', 'null' ], 354 | 'format' => 'date-time', 355 | 'context' => [ 'view' ], 356 | ], 357 | ], 358 | ]; 359 | 360 | $this->schema = $schema; 361 | 362 | return $this->add_additional_fields_schema( $this->schema ); 363 | } 364 | 365 | /** 366 | * Checks if a valid session was provided. 367 | * 368 | * @phpstan-param WP_REST_Request<array{jsonrpc: string, id?: string|number, method: string, params: array<string, mixed>}> $request 369 | * 370 | * @param WP_REST_Request $request Full details about the request. 371 | * @return true|WP_Error True if a valid session was provided, WP_Error object otherwise. 372 | */ 373 | protected function check_session( WP_REST_Request $request ): true|WP_Error { 374 | $session_id = (string) $request->get_header( self::SESSION_ID_HEADER ); 375 | 376 | if ( empty( $session_id ) ) { 377 | return new WP_Error( 378 | 'mcp_missing_session', 379 | __( 'Missing session.', 'mcp' ), 380 | array( 'status' => WP_Http::BAD_REQUEST ) 381 | ); 382 | } 383 | 384 | $session = $this->get_session( $session_id ); 385 | 386 | if ( null === $session ) { 387 | return new WP_Error( 388 | 'mcp_invalid_session', 389 | __( 'Session not found, it may have been terminated.', 'mcp' ), 390 | array( 'status' => WP_Http::NOT_FOUND ) 391 | ); 392 | } 393 | 394 | return true; 395 | } 396 | 397 | /** 398 | * Gets a session by its ID. 399 | * 400 | * @param string $session_id MCP session ID. 401 | * @return WP_Post|null Post object if ID is valid, null otherwise. 402 | */ 403 | protected function get_session( string $session_id ): ?WP_Post { 404 | $args = [ 405 | 'name' => $session_id, 406 | 'post_type' => 'mcp_session', 407 | 'post_status' => 'publish', 408 | 'numberposts' => 1, 409 | 'suppress_filters' => false, 410 | ]; 411 | 412 | $posts = get_posts( $args ); 413 | 414 | if ( empty( $posts ) ) { 415 | return null; 416 | } 417 | 418 | return $posts[0]; 419 | } 420 | } 421 | ```