# 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];
}
}
```