PluginProbe
Extendify / 3.2.2
Extendify v3.2.2
3.2.2 3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 All 128 releases
extendify / app / Mcp / Server.php

Server.php in Extendify 3.2.2, at app/Mcp/Server.php

454 lines 15.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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