#
tokens: 13333/50000 12/12 files
lines: off (toggle) GitHub
raw markdown copy
# 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

[![Commit activity](https://img.shields.io/github/commit-activity/m/mcp-wp/mcp-server)](https://github.com/mcp-wp/mcp-server/pulse/monthly)
[![Code Coverage](https://codecov.io/gh/mcp-wp/mcp-server/branch/main/graph/badge.svg)](https://codecov.io/gh/mcp-wp/mcp-server)
[![License](https://img.shields.io/github/license/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:

[![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)

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

```