| 1 |
<?php |
| 2 |
|
| 3 |
/** |
| 4 |
* The site's MCP endpoint. |
| 5 |
*/ |
| 6 |
|
| 7 |
namespace Extendify\Mcp; |
| 8 |
|
| 9 |
defined('ABSPATH') || die('No direct access.'); |
| 10 |
|
| 11 |
use Extendify\Config; |
| 12 |
use Extendify\Mcp\OAuth\Metadata; |
| 13 |
use Extendify\Mcp\OAuth\Tokens; |
| 14 |
use Extendify\PartnerData; |
| 15 |
|
| 16 |
/** |
| 17 |
* Answers JSON-RPC over a single POST route. |
| 18 |
* |
| 19 |
* Two kinds of client are served, told apart per request. A stateless client |
| 20 |
* (2026-07-28) names its protocol version in _meta on every message, opens with |
| 21 |
* server/discover, and is held to the Mcp-* headers. An initialize client |
| 22 |
* (2024-11-05 through 2025-11-25) negotiates a version once with initialize and |
| 23 |
* expects a session; this server never depended on one, so it answers both. |
| 24 |
*/ |
| 25 |
class Server |
| 26 |
{ |
| 27 |
// phpcs:disable PSR12.Properties.ConstantVisibility.NotFound |
| 28 |
/** |
| 29 |
* Clients that open with initialize. Their differences do not reach a server |
| 30 |
* answering only initialize, tools/list and tools/call. |
| 31 |
*/ |
| 32 |
const INITIALIZE_VERSIONS = ['2024-11-05', '2025-03-26', '2025-06-18', '2025-11-25']; |
| 33 |
|
| 34 |
/** |
| 35 |
* Offered to an initialize that asks for a version this server does not speak; |
| 36 |
* the spec wants the newest supported. |
| 37 |
*/ |
| 38 |
const INITIALIZE_FALLBACK = '2025-11-25'; |
| 39 |
|
| 40 |
/** |
| 41 |
* Clients that name the version in _meta on every request and open with server/discover. |
| 42 |
*/ |
| 43 |
const STATELESS_VERSIONS = ['2026-07-28']; |
| 44 |
|
| 45 |
const META = 'io.modelcontextprotocol/'; |
| 46 |
|
| 47 |
/** |
| 48 |
* The tool list moves with the partner config, which is refreshed every ten minutes. |
| 49 |
*/ |
| 50 |
const LIST_TTL_MS = 300000; |
| 51 |
// phpcs:enable PSR12.Properties.ConstantVisibility.NotFound |
| 52 |
|
| 53 |
/** |
| 54 |
* @var array|null |
| 55 |
*/ |
| 56 |
private static $connection = null; |
| 57 |
|
| 58 |
/** |
| 59 |
* @return void |
| 60 |
*/ |
| 61 |
public static function register() |
| 62 |
{ |
| 63 |
\add_action('rest_api_init', [self::class, 'registerRoute']); |
| 64 |
\add_filter('rest_request_after_callbacks', [self::class, 'challenge'], 10, 3); |
| 65 |
\add_filter('rest_pre_serve_request', [self::class, 'explain'], 10, 4); |
| 66 |
} |
| 67 |
|
| 68 |
/** |
| 69 |
* @return void |
| 70 |
*/ |
| 71 |
public static function registerRoute() |
| 72 |
{ |
| 73 |
\register_rest_route(Config::$slug . '/' . Config::$apiVersion, '/mcp', [ |
| 74 |
'methods' => 'GET, POST, DELETE', |
| 75 |
'callback' => [self::class, 'handle'], |
| 76 |
'permission_callback' => [self::class, 'authorize'], |
| 77 |
'show_in_index' => false, |
| 78 |
]); |
| 79 |
} |
| 80 |
|
| 81 |
/** |
| 82 |
* @param \WP_REST_Request $request - The incoming request. |
| 83 |
* @return true|\WP_Error |
| 84 |
*/ |
| 85 |
public static function authorize(\WP_REST_Request $request) |
| 86 |
{ |
| 87 |
$presented = self::bearer($request) !== null; |
| 88 |
$connection = Tokens::findAccess(self::bearer($request)); |
| 89 |
if (!$connection) { |
| 90 |
return self::unauthorized($presented); |
| 91 |
} |
| 92 |
|
| 93 |
PartnerData::refreshIfStale(); |
| 94 |
if (!Availability::live()) { |
| 95 |
return self::unauthorized($presented); |
| 96 |
} |
| 97 |
|
| 98 |
\wp_set_current_user($connection['userId']); |
| 99 |
if (!\current_user_can('manage_options')) { |
| 100 |
return self::unauthorized($presented); |
| 101 |
} |
| 102 |
|
| 103 |
self::$connection = $connection; |
| 104 |
Guard::mark(); |
| 105 |
|
| 106 |
return true; |
| 107 |
} |
| 108 |
|
| 109 |
/** |
| 110 |
* @param \WP_REST_Request $request - The incoming request. |
| 111 |
* @return string|null |
| 112 |
*/ |
| 113 |
private static function bearer(\WP_REST_Request $request) |
| 114 |
{ |
| 115 |
$sent = (string) $request->get_header('authorization'); |
| 116 |
|
| 117 |
return preg_match('/^Bearer\s+(\S+)$/i', $sent, $found) ? $found[1] : null; |
| 118 |
} |
| 119 |
|
| 120 |
/** |
| 121 |
* A site with the feature off looks the same as a bad token. |
| 122 |
* |
| 123 |
* @param boolean $presented - Whether the request carried a credential. |
| 124 |
* @return \WP_Error |
| 125 |
*/ |
| 126 |
private static function unauthorized($presented = false) |
| 127 |
{ |
| 128 |
$message = $presented |
| 129 |
? __( |
| 130 |
'This connection is not valid. Authorize this site again from the assistant that uses it.', |
| 131 |
'extendify-local' |
| 132 |
) |
| 133 |
: __('This address is the MCP endpoint of a WordPress site, not a page to read.', 'extendify-local') |
| 134 |
. ' ' . __( |
| 135 |
'Add it as a connector in an AI assistant, which will send the site owner here to approve it.', |
| 136 |
'extendify-local' |
| 137 |
); |
| 138 |
|
| 139 |
return new \WP_Error('extendify_mcp_unauthorized', $message, ['status' => 401]); |
| 140 |
} |
| 141 |
|
| 142 |
/** |
| 143 |
* Without this, a browser or a page-fetching model gets JSON it cannot act on. |
| 144 |
* |
| 145 |
* @param boolean $served - Whether a body has already been sent. |
| 146 |
* @param mixed $result - The response about to be served. |
| 147 |
* @param \WP_REST_Request $request - The incoming request. |
| 148 |
* @param \WP_REST_Server $server - The server serving it. |
| 149 |
* @return boolean |
| 150 |
*/ |
| 151 |
public static function explain($served, $result, $request, $server) |
| 152 |
{ |
| 153 |
if ($served || !($result instanceof \WP_REST_Response) || !self::wantsPage($request)) { |
| 154 |
return $served; |
| 155 |
} |
| 156 |
|
| 157 |
$data = (array) $result->get_data(); |
| 158 |
if ($result->get_status() !== 401 || ($data['code'] ?? '') !== 'extendify_mcp_unauthorized') { |
| 159 |
return $served; |
| 160 |
} |
| 161 |
|
| 162 |
$server->send_header('Content-Type', 'text/html; charset=' . \get_option('blog_charset')); |
| 163 |
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped as it is built. |
| 164 |
echo self::page(); |
| 165 |
|
| 166 |
return true; |
| 167 |
} |
| 168 |
|
| 169 |
/** |
| 170 |
* @param \WP_REST_Request $request - The incoming request. |
| 171 |
* @return boolean |
| 172 |
*/ |
| 173 |
private static function wantsPage(\WP_REST_Request $request) |
| 174 |
{ |
| 175 |
if (self::bearer($request) !== null) { |
| 176 |
return false; |
| 177 |
} |
| 178 |
|
| 179 |
return stripos((string) $request->get_header('accept'), 'text/html') !== false; |
| 180 |
} |
| 181 |
|
| 182 |
/** |
| 183 |
* @return string |
| 184 |
*/ |
| 185 |
private static function page() |
| 186 |
{ |
| 187 |
$title = \__('MCP endpoint', 'extendify-local'); |
| 188 |
|
| 189 |
return '<!DOCTYPE html><html ' . \get_language_attributes() . '><head>' |
| 190 |
. '<meta charset="' . \esc_attr(\get_option('blog_charset')) . '">' |
| 191 |
. '<meta name="viewport" content="width=device-width, initial-scale=1">' |
| 192 |
. '<meta name="robots" content="noindex">' |
| 193 |
. '<title>' . \esc_html($title) . '</title></head>' |
| 194 |
. '<body style="font: 16px/1.6 system-ui, sans-serif; margin: 3rem auto;' |
| 195 |
. ' max-width: 34rem; padding: 0 1rem;">' |
| 196 |
. '<h1 style="font-size: 1.4rem;">' . \esc_html($title) . '</h1>' |
| 197 |
. '<p>' . \esc_html__( |
| 198 |
'This address is how an AI assistant connects to this site. It is not a page to read.', |
| 199 |
'extendify-local' |
| 200 |
) . '</p>' |
| 201 |
. '<p>' . \esc_html__( |
| 202 |
'Add it as a connector in your assistant, and the assistant will send you back here to approve it.', |
| 203 |
'extendify-local' |
| 204 |
) . '</p>' |
| 205 |
. '<p><a href="' . \esc_url(\admin_url('options-general.php?page=' . Profile::PAGE)) . '">' |
| 206 |
. \esc_html__('Manage connections for this site', 'extendify-local') |
| 207 |
. '</a></p></body></html>'; |
| 208 |
} |
| 209 |
|
| 210 |
/** |
| 211 |
* A permission error reaches the client as a body, and only the header can |
| 212 |
* tell it where to authorize. |
| 213 |
* |
| 214 |
* @param mixed $response - The handler's answer, or the permission error. |
| 215 |
* @param array $handler - The matched route handler. |
| 216 |
* @param \WP_REST_Request $request - The incoming request. |
| 217 |
* @return mixed |
| 218 |
*/ |
| 219 |
public static function challenge($response, $handler, \WP_REST_Request $request) |
| 220 |
{ |
| 221 |
if (!\is_wp_error($response) || $response->get_error_code() !== 'extendify_mcp_unauthorized') { |
| 222 |
return $response; |
| 223 |
} |
| 224 |
|
| 225 |
$response = \rest_convert_error_to_response($response); |
| 226 |
$response->header('WWW-Authenticate', Metadata::challenge(self::bearer($request) !== null)); |
| 227 |
|
| 228 |
return $response; |
| 229 |
} |
| 230 |
|
| 231 |
/** |
| 232 |
* @param \WP_REST_Request $request - The incoming request. |
| 233 |
* @return \WP_REST_Response |
| 234 |
*/ |
| 235 |
public static function handle(\WP_REST_Request $request) |
| 236 |
{ |
| 237 |
// The GET event stream and the DELETE session end are optional in the spec. |
| 238 |
if ($request->get_method() !== 'POST') { |
| 239 |
return new \WP_REST_Response(null, 405); |
| 240 |
} |
| 241 |
|
| 242 |
// A browser sends Origin on a cross-site POST; a page holding the URL could otherwise drive the site. |
| 243 |
$origin = $request->get_header('origin'); |
| 244 |
if ($origin !== null && !self::ownOrigin($origin)) { |
| 245 |
return self::error(null, -32600, 'Origin not allowed', 403); |
| 246 |
} |
| 247 |
|
| 248 |
$message = $request->get_json_params(); |
| 249 |
if (!is_array($message) || !isset($message['method'])) { |
| 250 |
return self::error(null, -32600, 'Invalid Request'); |
| 251 |
} |
| 252 |
|
| 253 |
$params = is_array($message['params'] ?? null) ? $message['params'] : []; |
| 254 |
$meta = is_array($params['_meta'] ?? null) ? $params['_meta'] : []; |
| 255 |
$stateless = self::isStateless($meta); |
| 256 |
Connections::touch(self::$connection); |
| 257 |
|
| 258 |
// A message with no id is a notification and takes no response. |
| 259 |
if (!isset($message['id'])) { |
| 260 |
return new \WP_REST_Response(null, 202); |
| 261 |
} |
| 262 |
|
| 263 |
$id = $message['id']; |
| 264 |
$refused = $stateless ? self::held($request, $message, $meta) : null; |
| 265 |
if ($refused) { |
| 266 |
return $refused; |
| 267 |
} |
| 268 |
|
| 269 |
switch ($message['method']) { |
| 270 |
case 'server/discover': |
| 271 |
return self::result($id, self::discover()); |
| 272 |
case 'initialize': |
| 273 |
return self::result($id, self::initialize($params)); |
| 274 |
case 'ping': |
| 275 |
return self::result($id, []); |
| 276 |
case 'tools/list': |
| 277 |
return self::result($id, [ |
| 278 |
'tools' => Surface::tools(self::$connection['grants']), |
| 279 |
'ttlMs' => self::LIST_TTL_MS, |
| 280 |
'cacheScope' => 'private', |
| 281 |
]); |
| 282 |
case 'tools/call': |
| 283 |
$called = Surface::call($params, self::$connection['grants'], self::$connection); |
| 284 |
return \is_wp_error($called) |
| 285 |
? self::error($id, -32602, $called->get_error_message()) |
| 286 |
: self::result($id, $called); |
| 287 |
} |
| 288 |
|
| 289 |
return self::error($id, -32601, 'Method not found', $stateless ? 404 : 200); |
| 290 |
} |
| 291 |
|
| 292 |
/** |
| 293 |
* @param array $meta - The message's _meta. |
| 294 |
* @return boolean |
| 295 |
*/ |
| 296 |
private static function isStateless(array $meta) |
| 297 |
{ |
| 298 |
return array_key_exists(self::META . 'protocolVersion', $meta); |
| 299 |
} |
| 300 |
|
| 301 |
/** |
| 302 |
* The headers mirror the body for intermediaries, so a body they disagree with is refused. |
| 303 |
* |
| 304 |
* @param \WP_REST_Request $request - The incoming request. |
| 305 |
* @param array $message - The JSON-RPC message. |
| 306 |
* @param array $meta - The message's _meta. |
| 307 |
* @return \WP_REST_Response|null - The refusal, or null when the request stands. |
| 308 |
*/ |
| 309 |
private static function held(\WP_REST_Request $request, array $message, array $meta) |
| 310 |
{ |
| 311 |
$id = $message['id']; |
| 312 |
$version = (string) $meta[self::META . 'protocolVersion']; |
| 313 |
$mismatch = self::mismatch($request, 'MCP-Protocol-Version', $version) |
| 314 |
?: self::mismatch($request, 'Mcp-Method', (string) $message['method']); |
| 315 |
if (!$mismatch && $message['method'] === 'tools/call') { |
| 316 |
$mismatch = self::mismatch($request, 'Mcp-Name', (string) ($message['params']['name'] ?? '')); |
| 317 |
} |
| 318 |
|
| 319 |
if ($mismatch) { |
| 320 |
return self::error($id, -32020, $mismatch, 400); |
| 321 |
} |
| 322 |
|
| 323 |
if (!in_array($version, self::STATELESS_VERSIONS, true)) { |
| 324 |
return self::error($id, -32022, 'Unsupported protocol version', 400, [ |
| 325 |
'supported' => self::STATELESS_VERSIONS, |
| 326 |
'requested' => $version, |
| 327 |
]); |
| 328 |
} |
| 329 |
|
| 330 |
if (!array_key_exists(self::META . 'clientCapabilities', $meta)) { |
| 331 |
return self::error($id, -32602, 'Invalid params: _meta carries no clientCapabilities', 400); |
| 332 |
} |
| 333 |
|
| 334 |
return null; |
| 335 |
} |
| 336 |
|
| 337 |
/** |
| 338 |
* @param \WP_REST_Request $request - The incoming request. |
| 339 |
* @param string $header - The header that mirrors a body value. |
| 340 |
* @param string $expected - The body value it must match. |
| 341 |
* @return string - What went wrong, or an empty string. |
| 342 |
*/ |
| 343 |
private static function mismatch(\WP_REST_Request $request, $header, $expected) |
| 344 |
{ |
| 345 |
$sent = $request->get_header($header); |
| 346 |
if ($sent === null) { |
| 347 |
return sprintf('Header mismatch: %s is missing', $header); |
| 348 |
} |
| 349 |
|
| 350 |
if (self::decoded($sent) !== $expected) { |
| 351 |
return sprintf('Header mismatch: %s does not match the body', $header); |
| 352 |
} |
| 353 |
|
| 354 |
return ''; |
| 355 |
} |
| 356 |
|
| 357 |
/** |
| 358 |
* A value that is not plain ASCII travels as =?base64?...?=. |
| 359 |
* |
| 360 |
* @param string $value - The header value as sent. |
| 361 |
* @return string |
| 362 |
*/ |
| 363 |
private static function decoded($value) |
| 364 |
{ |
| 365 |
if (preg_match('/^=\?base64\?(.*)\?=$/', $value, $wrapped)) { |
| 366 |
return (string) base64_decode($wrapped[1], true); |
| 367 |
} |
| 368 |
|
| 369 |
return $value; |
| 370 |
} |
| 371 |
|
| 372 |
/** |
| 373 |
* @param string $origin - The Origin header as sent. |
| 374 |
* @return boolean |
| 375 |
*/ |
| 376 |
private static function ownOrigin($origin) |
| 377 |
{ |
| 378 |
$sent = strtolower((string) \wp_parse_url($origin, PHP_URL_HOST)); |
| 379 |
|
| 380 |
return $sent !== '' && $sent === strtolower((string) \wp_parse_url(\home_url(), PHP_URL_HOST)); |
| 381 |
} |
| 382 |
|
| 383 |
/** |
| 384 |
* @return array |
| 385 |
*/ |
| 386 |
private static function discover() |
| 387 |
{ |
| 388 |
return [ |
| 389 |
'supportedVersions' => self::STATELESS_VERSIONS, |
| 390 |
'capabilities' => ['tools' => (object) []], |
| 391 |
'ttlMs' => HOUR_IN_SECONDS * 1000, |
| 392 |
'cacheScope' => 'private', |
| 393 |
]; |
| 394 |
} |
| 395 |
|
| 396 |
/** |
| 397 |
* @param array $params - The initialize params. |
| 398 |
* @return array |
| 399 |
*/ |
| 400 |
private static function initialize(array $params) |
| 401 |
{ |
| 402 |
$asked = (string) ($params['protocolVersion'] ?? ''); |
| 403 |
|
| 404 |
return [ |
| 405 |
'protocolVersion' => in_array($asked, self::INITIALIZE_VERSIONS, true) ? $asked : self::INITIALIZE_FALLBACK, |
| 406 |
'capabilities' => ['tools' => (object) []], |
| 407 |
'serverInfo' => self::serverInfo(), |
| 408 |
]; |
| 409 |
} |
| 410 |
|
| 411 |
/** |
| 412 |
* @return array |
| 413 |
*/ |
| 414 |
private static function serverInfo() |
| 415 |
{ |
| 416 |
$header = \get_file_data(EXTENDIFY_PATH . 'extendify-plugin.php', ['Version' => 'Version']); |
| 417 |
|
| 418 |
return ['name' => 'extendify-wordpress', 'version' => $header['Version'] ?: '0.0.0']; |
| 419 |
} |
| 420 |
|
| 421 |
/** |
| 422 |
* An initialize client reads past resultType and _meta; a stateless one requires them. |
| 423 |
* |
| 424 |
* @param mixed $id - The JSON-RPC request id. |
| 425 |
* @param array $result - The result to send back. |
| 426 |
* @return \WP_REST_Response |
| 427 |
*/ |
| 428 |
private static function result($id, array $result) |
| 429 |
{ |
| 430 |
$result['resultType'] = 'complete'; |
| 431 |
$result['_meta'] = [self::META . 'serverInfo' => self::serverInfo()]; |
| 432 |
|
| 433 |
return new \WP_REST_Response(['jsonrpc' => '2.0', 'id' => $id, 'result' => $result]); |
| 434 |
} |
| 435 |
|
| 436 |
/** |
| 437 |
* @param mixed $id - The JSON-RPC request id, or null. |
| 438 |
* @param integer $code - The JSON-RPC error code. |
| 439 |
* @param string $message - The error message. |
| 440 |
* @param integer $status - The HTTP status to send it with. |
| 441 |
* @param mixed $data - Error data, if the code defines any. |
| 442 |
* @return \WP_REST_Response |
| 443 |
*/ |
| 444 |
private static function error($id, $code, $message, $status = 200, $data = null) |
| 445 |
{ |
| 446 |
$error = ['code' => $code, 'message' => $message]; |
| 447 |
if ($data !== null) { |
| 448 |
$error['data'] = $data; |
| 449 |
} |
| 450 |
|
| 451 |
return new \WP_REST_Response(['jsonrpc' => '2.0', 'id' => $id, 'error' => $error], $status); |
| 452 |
} |
| 453 |
} |
| 454 |
|