#
tokens: 17820/50000 12/12 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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 | [![Commit activity](https://img.shields.io/github/commit-activity/m/mcp-wp/mcp-server)](https://github.com/mcp-wp/mcp-server/pulse/monthly)
 4 | [![Code Coverage](https://codecov.io/gh/mcp-wp/mcp-server/branch/main/graph/badge.svg)](https://codecov.io/gh/mcp-wp/mcp-server)
 5 | [![License](https://img.shields.io/github/license/mcp-wp/mcp-server)](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 | [![Download latest nightly build](https://img.shields.io/badge/Download%20latest%20nightly-24282D?style=for-the-badge&logo=Files&logoColor=ffffff)](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 | 
```