# 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 # MCP Server for WordPress [](https://github.com/mcp-wp/mcp-server/pulse/monthly) [](https://codecov.io/gh/mcp-wp/mcp-server) [](https://github.com/mcp-wp/mcp-server/blob/main/LICENSE) [Model Context Protocol](https://modelcontextprotocol.io/) server using the WordPress REST API. Try it by installing and activating the latest nightly build on your own WordPress website: [](https://mcp-wp.github.io/mcp-server/mcp.zip) ## Description 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. 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. Note: the Streamable HTTP transport is not fully implemented yet and there are no tests. So it might not 100% work as expected. ## Usage 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). 1. Run `wp plugin install --activate https://github.com/mcp-wp/mcp-server/archive/refs/heads/main.zip` 2. Run `wp plugin install --activate ai-services` 3. Run `wp package install mcp-wp/ai-command:dev-main` 4. Run `wp mcp server add "mysite" "https://example.com/wp-json/mcp/v1/mcp"` 5. Run `wp ai "Greet my friend Pascal"` or so Note: The WP-CLI command also works on a local WordPress installation without this plugin. ``` -------------------------------------------------------------------------------- /mcp.php: -------------------------------------------------------------------------------- ```php <?php /** * Plugin Name: MCP Server for WordPress * Plugin URI: https://github.com/swissspidy/mcp * Description: MCP server implementation using the WordPress REST API. * Version: 0.1.0 * Author: Pascal Birchler * Author URI: https://pascalbirchler.com * License: Apache-2.0 * License URI: https://www.apache.org/licenses/LICENSE-2.0 * Text Domain: mcp * Requires at least: 6.7 * Requires PHP: 8.2 * Update URI: https://mcp-wp.github.io/mcp-server/update.json * * @package McpWp */ require_once __DIR__ . '/vendor/autoload.php'; register_activation_hook( __FILE__, 'McpWp\\activate_plugin' ); register_deactivation_hook( __FILE__, 'McpWp\\deactivate_plugin' ); \McpWp\boot(); ``` -------------------------------------------------------------------------------- /src/MCP/Servers/WordPress/Tools/Dummy.php: -------------------------------------------------------------------------------- ```php <?php namespace McpWp\MCP\Servers\WordPress\Tools; use Mcp\Types\TextContent; use McpWp\MCP\Server; /** * @phpstan-import-type ToolDefinition from Server */ readonly class Dummy { /** * Returns a list of dummy tools for testing. * * @return array<int, ToolDefinition> Tools. */ public function get_tools(): array { $tools = []; $tools[] = [ 'name' => 'greet-user', 'description' => 'Greet a given user by their name', 'inputSchema' => [ 'type' => 'object', 'properties' => [ 'name' => [ 'type' => 'string', 'description' => 'Name', ], ], 'required' => [ 'name' ], ], 'callback' => static function ( $arguments ) { $name = $arguments['name']; return new TextContent( "Hello my friend, $name" ); }, ]; return $tools; } } ``` -------------------------------------------------------------------------------- /src/MCP/Servers/WordPress/MediaManager.php: -------------------------------------------------------------------------------- ```php <?php namespace McpWp\MCP\Servers\WordPress; class MediaManager { public static function upload_to_media_library( string $media_path ): \WP_Error|int { // Get WordPress upload directory information $upload_dir = wp_upload_dir(); // Get the file name from the path $file_name = basename( $media_path ); // Copy file to the upload directory $new_file_path = $upload_dir['path'] . '/' . $file_name; copy( $media_path, $new_file_path ); // Prepare attachment data $wp_filetype = wp_check_filetype( $file_name, null ); $attachment = array( 'post_mime_type' => $wp_filetype['type'], 'post_title' => sanitize_file_name( $file_name ), 'post_content' => '', 'post_status' => 'inherit', ); // Insert the attachment $attach_id = wp_insert_attachment( $attachment, $new_file_path ); // Generate attachment metadata // @phpstan-ignore requireOnce.fileNotFound require_once ABSPATH . 'wp-admin/includes/image.php'; $attach_data = wp_generate_attachment_metadata( $attach_id, $new_file_path ); wp_update_attachment_metadata( $attach_id, $attach_data ); return $attach_id; } } ``` -------------------------------------------------------------------------------- /src/MCP/Servers/WordPress/WordPress.php: -------------------------------------------------------------------------------- ```php <?php namespace McpWp\MCP\Servers\WordPress; use Mcp\Types\Resource; use Mcp\Types\ResourceTemplate; use McpWp\MCP\Server; use McpWp\MCP\Servers\WordPress\Tools\CommunityEvents; use McpWp\MCP\Servers\WordPress\Tools\Dummy; use McpWp\MCP\Servers\WordPress\Tools\RestApi; use Psr\Log\LoggerInterface; class WordPress extends Server { public function __construct( ?LoggerInterface $logger = null ) { parent::__construct( 'WordPress', $logger ); $all_tools = [ ...( new RestApi( $this->logger ) )->get_tools(), ...( new CommunityEvents() )->get_tools(), ...( new Dummy() )->get_tools(), ]; /** * Filters all the tools exposed by the WordPress MCP server. * * @param array $all_tools MCP tools. */ $all_tools = apply_filters( 'mcp_wp_wordpress_tools', $all_tools ); foreach ( $all_tools as $tool ) { try { $this->register_tool( $tool ); } catch ( \Exception $e ) { $this->logger->debug( $e->getMessage() ); } } /** * Fires after tools have been registered in the WordPress MCP server. * * Can be used to register additional tools. * * @param Server $server WordPress MCP server instance. */ do_action( 'mcp_wp_wordpress_tools_loaded', $this ); $this->register_resource( new Resource( 'Greeting Text', 'example://greeting', 'A simple greeting message', 'text/plain' ) ); $this->register_resource_template( new ResourceTemplate( 'Attachment', 'media://{id}', 'WordPress attachment', 'application/octet-stream' ) ); } } ``` -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- ```json { "name": "mcp-wp/mcp-server", "description": "MCP Server for WordPress", "license": "Apache-2.0", "type": "wordpress-plugin", "authors": [ { "name": "Pascal Birchler", "email": "[email protected]", "homepage": "https://pascalbirchler.com", "role": "Developer" } ], "require": { "php": "^8.2", "logiscape/mcp-sdk-php": "dev-main" }, "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0", "php-stubs/wordpress-tests-stubs": "dev-master", "phpcompatibility/phpcompatibility-wp": "^2.0", "phpstan/extension-installer": "^1.3", "roave/security-advisories": "dev-latest", "szepeviktor/phpstan-wordpress": "^v2.0.1", "wp-coding-standards/wpcs": "^3.0.1", "yoast/phpunit-polyfills": "^4.0.0", "johnbillion/wp-compat": "^1.1", "phpstan/phpstan-strict-rules": "^2.0" }, "config": { "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true, "phpstan/extension-installer": true }, "platform": { "php": "8.2" }, "sort-packages": true }, "autoload": { "psr-4": { "McpWp\\": "src/" }, "files": [ "src/functions.php" ] }, "autoload-dev": { "psr-4": { "McpWp\\Tests\\": "tests/phpunit/tests/", "McpWp\\Tests_Includes\\": "tests/phpunit/includes/" } }, "scripts": { "format": "vendor/bin/phpcbf --report-summary --report-source .", "lint": "vendor/bin/phpcs --report-summary --report-source .", "phpstan": "phpstan analyse --memory-limit=2048M", "test": "vendor/bin/phpunit", "test:multisite": "vendor/bin/phpunit -c phpunit-multisite.xml.dist" } } ``` -------------------------------------------------------------------------------- /src/MCP/Servers/WordPress/Tools/RouteInformation.php: -------------------------------------------------------------------------------- ```php <?php declare(strict_types=1); namespace McpWp\MCP\Servers\WordPress\Tools; use BadMethodCallException; use WP_REST_Controller; use WP_REST_Posts_Controller; use WP_REST_Taxonomies_Controller; use WP_REST_Users_Controller; /** * RouteInformation helper class. */ readonly class RouteInformation { /** * Class constructor. * * @param string $route Route name. * @param string $method Method. * @param string $title Schema title. */ public function __construct( private string $route, private string $method, private string $title, ) {} /** * Returns a tool name based on the route and method. * * Example: DELETE wp/v2/users/me -> delete_wp_v2_users_me * * @return string Tool name. */ public function get_name(): string { $route = $this->route; preg_match_all( '/\(?P<(\w+)>/', $this->route, $matches ); foreach ( $matches[1] as $match ) { $route = (string) preg_replace( '/(\(\?P<' . $match . '>.*\))/', 'p_' . $match, $route, 1 ); } $suffix = sanitize_title( $route ); if ( '' === $suffix ) { $suffix = 'index'; } return strtolower( str_replace( '-', '_', $this->method . '_' . $suffix ) ); } /** * Returns a description based on the route and method. * * Examples: * * GET /wp/v2/posts -> Get a list of post items * GET /wp/v2/posts/(?P<id>[\d]+) -> Get a single post item */ public function get_description(): string { $verb = match ( $this->method ) { 'POST' => 'Create', 'PUT', 'PATCH' => 'Update', 'DELETE' => 'Delete', default => 'Get', }; $is_singular = str_ends_with( $this->route, '(?P<id>[\d]+)' ) || 'POST' === $this->method; $determiner = $is_singular ? 'a single' : 'a list of'; $title = '' !== $this->title ? "{$this->title} item" : 'item'; $title = $is_singular ? $title : $title . 's'; return $verb . ' ' . $determiner . ' ' . $title; } } ``` -------------------------------------------------------------------------------- /src/MCP/Servers/WordPress/Tools/CommunityEvents.php: -------------------------------------------------------------------------------- ```php <?php namespace McpWp\MCP\Servers\WordPress\Tools; use Mcp\Types\TextContent; use McpWp\MCP\Server; use WP_Community_Events; /** * CommunityEvents tool class. * * Demonstrates how an additional tool can be added to * provide some other information from WordPress beyond * the REST API routes. * * @phpstan-import-type ToolDefinition from Server */ readonly class CommunityEvents { /** * Returns a list of tools. * * @return array<int, ToolDefinition> Tools. */ public function get_tools(): array { $tools = []; $tools[] = [ 'name' => 'fetch_wp_community_events', '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' ), 'inputSchema' => [ 'type' => 'object', 'properties' => [ 'location' => [ 'type' => 'string', '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' ), ], ], 'required' => [ 'location' ], ], 'callback' => static function ( $params ) { $location_input = strtolower( trim( $params['location'] ) ); if ( ! class_exists( 'WP_Community_Events' ) ) { // @phpstan-ignore requireOnce.fileNotFound require_once ABSPATH . 'wp-admin/includes/class-wp-community-events.php'; } $location = [ 'description' => $location_input, ]; $events_instance = new WP_Community_Events( 0, $location ); // Get events from WP_Community_Events $events = $events_instance->get_events( $location_input ); // Check for WP_Error if ( is_wp_error( $events ) ) { return $events; } return new TextContent( json_encode( $events['events'], JSON_THROW_ON_ERROR ) ); }, ]; return $tools; } } ``` -------------------------------------------------------------------------------- /src/functions.php: -------------------------------------------------------------------------------- ```php <?php /** * Collection of functions. * * @package McpWp */ declare(strict_types = 1); namespace McpWp; use WP_User; use function add_action; /** * Bootstrap function. * * @return void */ function boot(): void { add_action( 'init', __NAMESPACE__ . '\register_session_post_type' ); add_action( 'rest_api_init', __NAMESPACE__ . '\register_rest_routes' ); add_action( 'mcp_sessions_cleanup', __NAMESPACE__ . '\delete_old_sessions' ); add_filter( 'update_plugins_mcp-wp.github.io', __NAMESPACE__ . '\filter_update_plugins', 10, 2 ); add_filter( 'determine_current_user', __NAMESPACE__ . '\validate_bearer_token', 30 ); } /** * Filters the update response for this plugin. * * Allows downloading updates from GitHub. * * @codeCoverageIgnore * * @param array<string,mixed>|false $update The plugin update data with the latest details. Default false. * @param array<string,string> $plugin_data Plugin headers. * * @return array<string,mixed>|false Filtered update data. */ function filter_update_plugins( $update, $plugin_data ) { // @phpstan-ignore requireOnce.fileNotFound require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; $updater = new \WP_Automatic_Updater(); if ( $updater->is_vcs_checkout( dirname( __DIR__ ) ) ) { return $update; } $response = wp_remote_get( $plugin_data['UpdateURI'] ); $response = wp_remote_retrieve_body( $response ); if ( '' === $response ) { return $update; } /** * Encoded update data. * * @var array<string,mixed> $result */ $result = json_decode( $response, true ); return $result; } /** * Plugin activation hook. * * @codeCoverageIgnore * * @return void */ function activate_plugin(): void { register_session_post_type(); if ( false === wp_next_scheduled( 'mcp_sessions_cleanup' ) ) { wp_schedule_event( time(), 'hourly', 'mcp_sessions_cleanup' ); } } /** * Plugin deactivation hook. * * @codeCoverageIgnore * * @return void */ function deactivate_plugin(): void { unregister_post_type( 'mcp_session' ); $timestamp = wp_next_scheduled( 'mcp_sessions_cleanup' ); if ( false !== $timestamp ) { wp_unschedule_event( $timestamp, 'mcp_sessions_cleanup' ); } } /** * Registers a new post type for MCP sessions. * * @return void */ function register_session_post_type(): void { register_post_type( 'mcp_session', [ 'label' => __( 'MCP Sessions', 'mcp' ), 'public' => false, // @phpstan-ignore cast.useless 'show_ui' => defined( 'WP_DEBUG' ) && (bool) WP_DEBUG, // For debugging. ] ); } /** * Registers the MCP server REST API routes. * * @return void */ function register_rest_routes(): void { $controller = new RestController(); $controller->register_routes(); } /** * Delete unresolved upload requests that are older than 1 day. * * @return void */ function delete_old_sessions(): void { $args = [ 'post_type' => 'mcp_session', 'post_status' => 'publish', 'numberposts' => -1, 'date_query' => [ [ 'before' => '1 day ago', 'inclusive' => true, ], ], 'suppress_filters' => false, ]; $posts = get_posts( $args ); foreach ( $posts as $post ) { wp_delete_post( $post->ID, true ); } } /** * Validates the application password credentials passed via `Authorization` header. * * @param int|false $input_user User ID if one has been determined, false otherwise. * @return int|false The authenticated user ID if successful, false otherwise. */ function validate_bearer_token( $input_user ) { // Don't authenticate twice. if ( ! empty( $input_user ) ) { return $input_user; } if ( ! wp_is_application_passwords_available() ) { return $input_user; } if ( ! isset( $_SERVER['HTTP_AUTHORIZATION'] ) || ! is_string( $_SERVER['HTTP_AUTHORIZATION'] ) ) { return $input_user; } $matches = []; $match = preg_match( '/^Bearer (?<user>.*):(?<password>.*)$/', $_SERVER['HTTP_AUTHORIZATION'], $matches ); if ( 1 !== $match ) { return $input_user; } $authenticated = wp_authenticate_application_password( null, $matches['user'], $matches['password'] ); if ( $authenticated instanceof WP_User ) { return $authenticated->ID; } // If it wasn't a user what got returned, just pass on what we had received originally. return $input_user; } ``` -------------------------------------------------------------------------------- /src/MCP/Servers/WordPress/Tools/RestApi.php: -------------------------------------------------------------------------------- ```php <?php namespace McpWp\MCP\Servers\WordPress\Tools; use McpWp\MCP\Server; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use WP_REST_Request; use WP_REST_Response; /** * REST API tools class. * * @phpstan-import-type ToolDefinition from Server * @phpstan-import-type ToolInputSchema from Server * @phpstan-type ArgumentSchema array{description?: string, type?: string, required?: bool} */ readonly class RestApi { private LoggerInterface $logger; /** * Constructor. * * @param LoggerInterface|null $logger Logger. */ public function __construct( ?LoggerInterface $logger = null ) { $this->logger = $logger ?? new NullLogger(); } /** * Returns a list of tools for all REST API routes. * * @throws \Exception * * @return array<int, ToolDefinition> Tools. */ public function get_tools(): array { $server = rest_get_server(); $routes = $server->get_routes(); $namespaces = $server->get_namespaces(); $tools = []; foreach ( $routes as $route => $handlers ) { // Do not include namespace routes in the response. if ( in_array( ltrim( $route, '/' ), $namespaces, true ) ) { continue; } /** * @param array{methods: array<string, mixed>, accept_json: bool, accept_raw: bool, show_in_index: bool, args: array, callback: array, permission_callback?: array} $handler */ foreach ( $handlers as $handler ) { /** * Methods for this route, e.g. 'GET', 'POST', 'PUT', etc. * * @var string[] $methods */ $methods = array_keys( $handler['methods'] ); // If WP_REST_Server::EDITABLE is used, keeo only POST but not PUT or PATCH. if ( in_array( 'POST', $methods, true ) ) { $methods = array_diff( $methods, [ 'PUT', 'PATCH' ] ); } foreach ( $methods as $method ) { $title = ''; if ( in_array( "$method $route", [ 'GET/', 'POST /batch/v1', ], true ) ) { continue; } if ( is_array( $handler['callback'] ) && isset( $handler['callback'][0] ) && $handler['callback'][0] instanceof \WP_REST_Controller ) { $controller = $handler['callback'][0]; $schema = $controller->get_public_item_schema(); if ( isset( $schema['title'] ) ) { $title = $schema['title']; } } if ( isset( $handler['permission_callback'] ) && is_callable( $handler['permission_callback'] ) ) { $has_required_parameter = (bool) preg_match_all( '/\(?P<(\w+)>/', $route ); if ( ! $has_required_parameter ) { /** * Permission callback result. * * @var bool|\WP_Error $result */ $result = call_user_func( $handler['permission_callback'], new WP_REST_Request() ); if ( true !== $result ) { continue; } } } $information = new RouteInformation( $route, $method, $title, ); // Autosaves or revisions controller could come up multiple times, skip in that case. if ( array_key_exists( $information->get_name(), $tools ) ) { continue; } $tool = [ 'name' => $information->get_name(), 'description' => $information->get_description(), 'inputSchema' => $this->args_to_schema( $handler['args'] ), 'annotations' => [ // A human-readable title for the tool. 'title' => null, // TODO: Add titles. // If true, the tool does not modify its environment. 'readOnlyHint' => 'GET' === $method, // This property is meaningful only when `readOnlyHint == false` 'idempotentHint' => 'GET' === $method, // Whether the tool may perform destructive updates to its environment. // This property is meaningful only when `readOnlyHint == false` 'destructiveHint' => 'DELETE' === $method, ], 'callback' => function ( $params ) use ( $route, $method ) { return json_encode( $this->rest_callable( $route, $method, $params ), JSON_THROW_ON_ERROR ); }, ]; $tools[ $information->get_name() ] = $tool; } } } return array_values( $tools ); } /** * REST route tool callback. * * @throws \JsonException * * @param string $route Route * @param string $method HTTP method. * @param array<string, mixed> $params Route params. * @return array<string, mixed> REST response data. */ private function rest_callable( string $route, string $method, array $params ): array { $server = rest_get_server(); preg_match_all( '/\(?P<(\w+)>/', $route, $matches ); foreach ( $matches[1] as $match ) { if ( array_key_exists( $match, $params ) ) { $route = (string) preg_replace( '/(\(\?P<' . $match . '>.*?\))/', // @phpstan-ignore cast.string (string) $params[ $match ], $route, 1 ); unset( $params[ $match ] ); } } // Fix incorrect meta inputs. if ( isset( $params['meta'] ) ) { if ( false === $params['meta'] || '' === $params['meta'] || [] === $params['meta'] ) { unset( $params['meta'] ); } } $this->logger->debug( "$method $route with input: " . json_encode( $params, JSON_THROW_ON_ERROR ) ); $request = new WP_REST_Request( $method, $route ); $request->set_body_params( $params ); /** * REST API response. * * @var WP_REST_Response $response */ $response = $server->dispatch( $request ); /** * Response data. * * @phpstan-var array<string, mixed> $data */ $data = $server->response_to_data( $response, false ); // @phpstan-ignore varTag.type // Reduce amount of data that is returned. unset( $data['_links'], $data['_embedded'] ); foreach ( $data as &$item ) { if ( is_array( $item ) ) { unset( $item['_links'], $item['_embedded'] ); } } return $data; } /** * @throws \Exception * * @param array<string, mixed> $args REST API route arguments. * @return array<string, mixed> Normalized schema. * * @phpstan-param array<string, ArgumentSchema> $args REST API route arguments. * @phpstan-return ToolInputSchema */ private function args_to_schema( array $args = [] ): array { $schema = []; $required = []; if ( empty( $args ) ) { return [ 'type' => 'object', 'properties' => [], 'required' => [], ]; } foreach ( $args as $title => $arg ) { $description = $arg['description'] ?? $title; $type = $this->sanitize_type( $arg['type'] ?? 'string' ); $schema[ $title ] = [ 'type' => $type, 'description' => $description, ]; if ( isset( $arg['required'] ) && true === $arg['required'] ) { $required[] = $title; } } return [ 'type' => 'object', 'properties' => $schema, 'required' => $required, ]; } /** * Normalize a type from REST API schema to MCP PHP SDK JSON schema. * * @param mixed $type Type. * @return string Normalized type. * @throws \Exception */ private function sanitize_type( $type ): string { $mapping = array( 'string' => 'string', 'integer' => 'integer', 'int' => 'integer', 'number' => 'integer', 'boolean' => 'boolean', 'bool' => 'boolean', ); // Validated types: if ( ! \is_array( $type ) && isset( $mapping[ $type ] ) ) { return $mapping[ $type ]; } if ( 'array' === $type || 'object' === $type ) { return 'string'; // TODO, better solution. } if ( empty( $type ) || 'null' === $type ) { return 'string'; } if ( ! \is_array( $type ) ) { // @phpstan-ignore binaryOp.invalid throw new \Exception( 'Invalid type: ' . $type ); } // Find valid values in array. if ( \in_array( 'string', $type, true ) ) { return 'string'; } if ( \in_array( 'integer', $type, true ) ) { return 'integer'; } // TODO, better types handling. return 'string'; } } ``` -------------------------------------------------------------------------------- /src/MCP/Server.php: -------------------------------------------------------------------------------- ```php <?php namespace McpWp\MCP; use InvalidArgumentException; use Mcp\Server\NotificationOptions; use Mcp\Server\Server as McpServer; use Mcp\Shared\ErrorData; use Mcp\Shared\McpError; use Mcp\Shared\Version; use Mcp\Types\CallToolResult; use Mcp\Types\Implementation; use Mcp\Types\InitializeResult; use Mcp\Types\JSONRPCError; use Mcp\Types\JsonRpcErrorObject; use Mcp\Types\JsonRpcMessage; use Mcp\Types\JSONRPCNotification; use Mcp\Types\JSONRPCRequest; use Mcp\Types\JSONRPCResponse; use Mcp\Types\ListResourcesResult; use Mcp\Types\ListResourceTemplatesResult; use Mcp\Types\ListToolsResult; use Mcp\Types\ReadResourceResult; use Mcp\Types\RequestId; use Mcp\Types\RequestParams; use Mcp\Types\Resource; use Mcp\Types\ResourceTemplate; use Mcp\Types\Result; use Mcp\Types\TextContent; use Mcp\Types\TextResourceContents; use Mcp\Types\Tool; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; /** * @phpstan-type ToolInputSchema array{type: string, properties: array<string, mixed>, required: string[]} * @phpstan-type ToolDefinition array{name: string, description?: string, callback: callable, inputSchema: ToolInputSchema} */ class Server { /** * @var array<string, array{tool: Tool, callback: callable}> */ private array $tools = []; /** * @var Array<Resource> */ private array $resources = []; /** * @var Array<ResourceTemplate> */ private array $resource_templates = []; protected McpServer $mcp_server; protected LoggerInterface $logger; public function __construct( private readonly string $name, ?LoggerInterface $logger = null ) { $this->logger = $logger ?? new NullLogger(); $this->mcp_server = new McpServer( $name, $this->logger ); $this->mcp_server->registerHandler( 'initialize', [ $this, 'initialize' ] ); $this->mcp_server->registerHandler( 'tools/list', [ $this, 'list_tools' ] ); $this->mcp_server->registerHandler( 'tools/call', [ $this, 'call_tool' ] ); $this->mcp_server->registerHandler( 'resources/list', [ $this, 'list_resources' ] ); $this->mcp_server->registerHandler( 'resources/read', [ $this, 'read_resources' ] ); $this->mcp_server->registerHandler( 'resources/templates/list', [ $this, 'list_resource_templates' ] ); $this->mcp_server->registerNotificationHandler( 'notifications/initialized', [ $this, 'do_nothing' ] ); } public function do_nothing(): void { // Do nothing. } /** * Registers a new MCP tool. * * @param ToolDefinition $tool_definition Tool definition. * @return void */ public function register_tool( array $tool_definition ): void { $name = $tool_definition['name']; $callable = $tool_definition['callback']; if ( strlen( $name ) > 64 ) { if ( 1 !== preg_match( '/^[a-zA-Z0-9_-]{1,64}$/', $name ) ) { throw new InvalidArgumentException( "Tool names should match pattern '^[a-zA-Z0-9_-]{1,64}$'. Received: '$name'." ); } } if ( array_key_exists( $name, $this->tools ) ) { throw new InvalidArgumentException( "Tool $name is already registered" ); } foreach ( $tool_definition['inputSchema']['properties'] as $property => $schema ) { // Anthropic has strict requirements for property keys. if ( 1 !== preg_match( '/^[a-zA-Z0-9_-]{1,64}$/', $property ) ) { throw new InvalidArgumentException( "Property keys should match pattern '^[a-zA-Z0-9_-]{1,64}$'. Received: '$property' (tool: $name)." ); } } $this->tools[ $name ] = [ 'tool' => Tool::fromArray( $tool_definition ), 'callback' => $callable, ]; } public function initialize(): InitializeResult { return new InitializeResult( capabilities: $this->mcp_server->getCapabilities( new NotificationOptions(), [] ), serverInfo: new Implementation( $this->name, '0.0.1', // TODO: Make dynamic. ), protocolVersion: Version::LATEST_PROTOCOL_VERSION ); } // TODO: Implement pagination, see https://spec.modelcontextprotocol.io/specification/2024-11-05/server/utilities/pagination/#response-format // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found public function list_tools( RequestParams $params ): ListToolsResult { $prepared_tools = []; foreach ( $this->tools as $tool ) { $prepared_tools[] = $tool['tool']; } return new ListToolsResult( $prepared_tools ); } public function call_tool( RequestParams $params ): CallToolResult { $found_tool = null; foreach ( $this->tools as $name => $tool ) { // @phpstan-ignore property.notFound if ( $name === $params->name ) { $found_tool = $tool; break; } } if ( null === $found_tool ) { // @phpstan-ignore property.notFound throw new InvalidArgumentException( "Unknown tool: {$params->name}" ); } // @phpstan-ignore property.notFound $result = call_user_func( $found_tool['callback'], $params->arguments ); if ( $result instanceof CallToolResult ) { return $result; } if ( is_wp_error( $result ) ) { return new CallToolResult( [ new TextContent( $result->get_error_message() ), ], true ); } if ( is_string( $result ) ) { $result = [ new TextContent( $result ) ]; } if ( ! is_array( $result ) ) { $result = [ $result ]; } return new CallToolResult( $result ); } // TODO: Implement pagination, see https://spec.modelcontextprotocol.io/specification/2024-11-05/server/utilities/pagination/#response-format // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found public function list_resources(): ListResourcesResult { return new ListResourcesResult( $this->resources ); } // TODO: Make dynamic. public function read_resources( RequestParams $params ): ReadResourceResult { // @phpstan-ignore property.notFound $uri = $params->uri; if ( 'example://greeting' !== $uri ) { throw new InvalidArgumentException( "Unknown resource: {$uri}" ); } return new ReadResourceResult( [ new TextResourceContents( 'Hello from the example MCP server!', $uri, 'text/plain' ), ] ); } /** * Registers a single resource. * * @param Resource $res Resource * @return void */ public function register_resource( Resource $res ): void { $this->resources[ $res->name ] = $res; } // TODO: Implement pagination, see https://spec.modelcontextprotocol.io/specification/2024-11-05/server/utilities/pagination/#response-format // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found public function list_resource_templates( RequestParams $params ): ListResourceTemplatesResult { return new ListResourceTemplatesResult( $this->resource_templates ); } /** * Registers a resource template. * * @param ResourceTemplate $resource_template Resource template. * @return void */ public function register_resource_template( ResourceTemplate $resource_template ): void { $this->resource_templates[ $resource_template->name ] = $resource_template; } /** * Processes an incoming message from the client. * * @param JsonRpcMessage $message * @return JsonRpcMessage|void|null */ public function handle_message( JsonRpcMessage $message ) { $this->logger->debug( 'Received message: ' . json_encode( $message ) ); $inner_message = $message->message; try { if ( $inner_message instanceof JSONRPCRequest ) { // It's a request return $this->process_request( $inner_message ); } if ( $inner_message instanceof JSONRPCNotification ) { // It's a notification $this->process_notification( $inner_message ); return null; } // Server does not expect responses from client; ignore or log $this->logger->warning( 'Received unexpected message type: ' . get_class( $inner_message ) ); } catch ( McpError $e ) { if ( $inner_message instanceof JSONRPCRequest ) { return $this->send_error( $inner_message->id, $e->error ); } } catch ( \Exception $e ) { $this->logger->error( 'Error handling message: ' . $e->getMessage() ); if ( $inner_message instanceof JSONRPCRequest ) { // Code -32603 is Internal error as per JSON-RPC spec return $this->send_error( $inner_message->id, new ErrorData( -32603, $e->getMessage() ) ); } } } /** * Processes a JSONRPCRequest message. */ private function process_request( JSONRPCRequest $request ): JsonRpcMessage { $method = $request->method; $handlers = $this->mcp_server->getHandlers(); $handler = $handlers[ $method ] ?? null; if ( null === $handler ) { throw new McpError( new ErrorData( -32601, // Method not found "Method not found: {$method}" ) ); } $params = $request->params ?? null; $result = $handler( $params ); if ( ! $result instanceof Result ) { $result = new Result(); } return $this->send_response( $request->id, $result ); } /** * Processes a JSONRPCNotification message. */ private function process_notification( JSONRPCNotification $notification ): void { $method = $notification->method; $handlers = $this->mcp_server->getNotificationHandlers(); $handler = $handlers[ $method ] ?? null; if ( null !== $handler ) { $params = $notification->params ?? null; $handler( $params ); } $this->logger->warning( "No handler registered for notification method: $method" ); } /** * Sends a response to a request. * * @param RequestId $id The request ID to respond to. * @param Result $result The result object. */ private function send_response( RequestId $id, Result $result ): JsonRpcMessage { // Create a JSONRPCResponse object and wrap in JsonRpcMessage $response = new JSONRPCResponse( '2.0', $id, $result ); $response->validate(); return new JsonRpcMessage( $response ); } /** * Sends an error response to a request. * * @param RequestId $id The request ID to respond to. * @param ErrorData $error The error data. */ private function send_error( RequestId $id, ErrorData $error ): JsonRpcMessage { $error_object = new JsonRpcErrorObject( $error->code, $error->message, $error->data ?? null ); $response = new JSONRPCError( '2.0', $id, $error_object ); $response->validate(); return new JsonRpcMessage( $response ); } } ``` -------------------------------------------------------------------------------- /src/RestController.php: -------------------------------------------------------------------------------- ```php <?php /** * Main REST API controller. * * @package McpWp */ declare(strict_types = 1); namespace McpWp; use Mcp\Types\InitializeResult; use Mcp\Types\JSONRPCError; use Mcp\Types\JsonRpcErrorObject; use Mcp\Types\JsonRpcMessage; use Mcp\Types\JSONRPCNotification; use Mcp\Types\JSONRPCRequest; use Mcp\Types\JSONRPCResponse; use Mcp\Types\NotificationParams; use Mcp\Types\RequestId; use Mcp\Types\RequestParams; use McpWp\MCP\Servers\WordPress\WordPress; use WP_Error; use WP_Http; use WP_Post; use WP_REST_Controller; use WP_REST_Request; use WP_REST_Response; use WP_REST_Server; /** * MCP REST API controller. */ class RestController extends WP_REST_Controller { /** * MCP session ID header name. */ protected const SESSION_ID_HEADER = 'Mcp-Session-Id'; /** * The namespace of this controller's route. * * @var string */ protected $namespace = 'mcp/v1'; /** * Registers the routes for the objects of the controller. * * @see register_rest_route() */ public function register_routes(): void { register_rest_route( $this->namespace, '/mcp', [ [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $this, 'create_item' ], 'permission_callback' => [ $this, 'create_item_permissions_check' ], 'args' => [ 'jsonrpc' => [ 'type' => 'string', 'enum' => [ '2.0' ], 'description' => __( 'JSON-RPC protocol version.', 'mcp' ), 'required' => true, ], 'id' => [ 'type' => [ 'string', 'integer' ], 'description' => __( 'Identifier established by the client.', 'mcp' ), // It should be required, but it's not sent for things like notifications. 'required' => false, ], 'method' => [ 'type' => 'string', 'description' => __( 'Method to be invoked.', 'mcp' ), 'required' => true, ], 'params' => [ 'type' => 'object', 'description' => __( 'Method to be invoked.', 'mcp' ), ], ], ], [ 'methods' => WP_REST_Server::DELETABLE, 'callback' => [ $this, 'delete_item' ], 'permission_callback' => [ $this, 'delete_item_permissions_check' ], ], [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'get_item' ], 'permission_callback' => [ $this, 'get_item_permissions_check' ], ], 'schema' => [ $this, 'get_public_item_schema' ], ] ); } /** * Checks if a given request has access to create items. * * @phpstan-param WP_REST_Request<array{jsonrpc: string, id?: string|number, method: string, params: array<string, mixed>}> $request * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise. */ public function create_item_permissions_check( $request ): true|WP_Error { if ( ! is_user_logged_in() ) { return new WP_Error( 'rest_not_logged_in', __( 'You are not currently logged in.', 'mcp' ), array( 'status' => WP_Http::UNAUTHORIZED ) ); } if ( 'initialize' !== $request['method'] ) { return $this->check_session( $request ); } return true; } /** * Creates one item from the collection. * * @todo Support batch requests * * @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 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function create_item( $request ): WP_Error|WP_REST_Response { $message = new JsonRpcMessage( new JSONRPCError( '2.0', new RequestId( '0' ), new JsonRpcErrorObject( -32600, 'Invalid JSON-RPC message structure.', null ) ) ); if ( isset( $request['method'] ) ) { // It's a Request or Notification if ( isset( $request['id'] ) ) { $params = new RequestParams(); if ( isset( $request['params'] ) ) { foreach ( $request['params'] as $key => $value ) { // @phpstan-ignore property.dynamicName $params->{$key} = $value; } } $message = new JsonRpcMessage( new JSONRPCRequest( '2.0', new RequestId( (string) $request['id'] ), isset( $request['params'] ) ? $params : null, $request['method'], ) ); } else { $params = new NotificationParams(); if ( isset( $request['params'] ) ) { foreach ( $request['params'] as $key => $value ) { // @phpstan-ignore property.dynamicName $params->{$key} = $value; } } $message = new JsonRpcMessage( new JSONRPCNotification( '2.0', isset( $request['params'] ) ? $params : null, $request['method'], ) ); } } elseif ( isset( $request['result'] ) || isset( $request['error'] ) ) { // TODO: Can the client actually send errors? // TODO: Can the client actually send results? // It's a Response or Error if ( isset( $request['error'] ) ) { // It's an Error $error_data = $request['error']; $message = new JsonRpcMessage( new JSONRPCError( '2.0', new RequestId( (string) ( $request['id'] ?? 0 ) ), new JsonRpcErrorObject( $error_data['code'], $error_data['message'], $error_data['data'] ?? null ) ) ); } else { // It's a Response $message = new JsonRpcMessage( new JSONRPCResponse( '2.0', new RequestId( (string) ( $request['id'] ?? 0 ) ), $request['result'] ) ); } } $server = new WordPress(); $mcp_response = $server->handle_message( $message ); $response = new WP_REST_Response(); if ( null !== $mcp_response ) { $response->set_data( $mcp_response ); } else { $response->set_status( 202 ); } // @phpstan-ignore property.notFound if ( isset( $mcp_response ) && $mcp_response->message->result instanceof InitializeResult ) { $uuid = wp_generate_uuid4(); wp_insert_post( [ 'post_type' => 'mcp_session', 'post_status' => 'publish', 'post_title' => $uuid, 'post_name' => $uuid, ] ); $response->header( self::SESSION_ID_HEADER, $uuid ); } // Quick workaround for MCP Inspector. $response->header( 'Access-Control-Allow-Origin', '*' ); // TODO: send right status code. return $response; } /** * Checks if a given request has access to terminate an MCP session. * * @phpstan-param WP_REST_Request<array{jsonrpc: string, id?: string|number, method: string, params: array<string, mixed>}> $request * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has access to delete the item, WP_Error object otherwise. */ public function delete_item_permissions_check( $request ): true|WP_Error { if ( ! is_user_logged_in() ) { return new WP_Error( 'rest_not_logged_in', __( 'You are not currently logged in.', 'mcp' ), array( 'status' => WP_Http::UNAUTHORIZED ) ); } return $this->check_session( $request ); } /** * Terminates an MCP session. * * @phpstan-param WP_REST_Request<array{jsonrpc: string, id?: string|number, method: string, params: array<string, mixed>}> $request * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function delete_item( $request ): WP_Error|WP_REST_Response { /** * Session post object. * * @var WP_Post $session */ $session = $this->get_session( (string) $request->get_header( self::SESSION_ID_HEADER ) ); wp_delete_post( $session->ID, true ); return new WP_REST_Response( '' ); } /** * Checks if a given request has access to get a specific item. * * @phpstan-param WP_REST_Request<array{jsonrpc: string, id?: string|number, method: string, params: array<string, mixed>}> $request * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise. */ public function get_item_permissions_check( $request ): true|WP_Error { if ( ! is_user_logged_in() ) { return new WP_Error( 'rest_not_logged_in', __( 'You are not currently logged in.', 'mcp' ), array( 'status' => WP_Http::UNAUTHORIZED ) ); } $session = $this->check_session( $request ); if ( is_wp_error( $session ) ) { return $session; } return new WP_Error( 'mcp_sse_not_supported', __( 'Server does not currently offer an SSE stream.', 'mcp' ), array( 'status' => WP_Http::METHOD_NOT_ALLOWED ) ); } /** * Retrieves the post's schema, conforming to JSON Schema. * * @return array<string, mixed> Item schema data. */ public function get_item_schema() { if ( null !== $this->schema ) { return $this->add_additional_fields_schema( $this->schema ); } $schema = [ '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => __( 'MCP Server', 'mcp' ), 'type' => 'object', // Base properties for every Post. 'properties' => [ 'jsonrpc' => [ 'description' => __( 'JSON-RPC protocol version.', 'mcp' ), 'type' => 'string', 'context' => [ 'view' ], ], 'id' => [ 'description' => __( 'Identifier established by the client.', 'mcp' ), 'type' => [ 'string', 'integer' ], 'context' => [ 'view' ], ], 'result' => [ 'description' => __( 'Result', 'mcp' ), 'type' => [ 'object' ], 'context' => [ 'view' ], ], 'date_gmt' => [ 'description' => __( 'The date the post was published, as GMT.' ), 'type' => [ 'string', 'null' ], 'format' => 'date-time', 'context' => [ 'view' ], ], ], ]; $this->schema = $schema; return $this->add_additional_fields_schema( $this->schema ); } /** * Checks if a valid session was provided. * * @phpstan-param WP_REST_Request<array{jsonrpc: string, id?: string|number, method: string, params: array<string, mixed>}> $request * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if a valid session was provided, WP_Error object otherwise. */ protected function check_session( WP_REST_Request $request ): true|WP_Error { $session_id = (string) $request->get_header( self::SESSION_ID_HEADER ); if ( empty( $session_id ) ) { return new WP_Error( 'mcp_missing_session', __( 'Missing session.', 'mcp' ), array( 'status' => WP_Http::BAD_REQUEST ) ); } $session = $this->get_session( $session_id ); if ( null === $session ) { return new WP_Error( 'mcp_invalid_session', __( 'Session not found, it may have been terminated.', 'mcp' ), array( 'status' => WP_Http::NOT_FOUND ) ); } return true; } /** * Gets a session by its ID. * * @param string $session_id MCP session ID. * @return WP_Post|null Post object if ID is valid, null otherwise. */ protected function get_session( string $session_id ): ?WP_Post { $args = [ 'name' => $session_id, 'post_type' => 'mcp_session', 'post_status' => 'publish', 'numberposts' => 1, 'suppress_filters' => false, ]; $posts = get_posts( $args ); if ( empty( $posts ) ) { return null; } return $posts[0]; } } ```