PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 All 200 releases
betterdocs / includes / Mcp / MCPServer.php

MCPServer.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.2, at includes/Mcp/MCPServer.php

635 lines 18.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP server — the per-site JSON-RPC endpoint.
4 *
5 * @package BetterDocs
6 * @since 4.9.0
7 */
8
9 namespace WPDeveloper\BetterDocs\Mcp;
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit; // Exit if accessed directly.
13 }
14
15 use WPDeveloper\BetterDocs\Abilities\AbilityError;
16 use WPDeveloper\BetterDocs\Abilities\AbilitiesRegistrar;
17
18 /**
19 * BetterDocs speaks MCP directly at this site's own URL
20 * (`https://thissite.com/betterdocs/mcp`), so no hosted broker is in the path.
21 * MCP's Streamable-HTTP transport is JSON-RPC 2.0 over HTTP POST; this class is
22 * the small server surface an AI client needs:
23 *
24 * - `initialize` → protocol version, capabilities, serverInfo, instructions
25 * - `ping` → `{}`
26 * - `tools/list` → {@see MCPTools::list()}
27 * - `tools/call` → {@see MCPTools::invoke()}, wrapped as MCP content
28 * - `notifications/*` → acknowledged with no body (202)
29 * - batches → an array of messages, notifications dropped from the reply
30 *
31 * **Auth** is a Bearer credential, either the static pairing token
32 * ({@see MCPPairing}) or an OAuth 2.1 access token ({@see MCPOAuth}). On
33 * success the request runs *as* the granting user, so every ability's own
34 * `current_user_can()` check still applies and a credential can never exceed
35 * what the person who granted it could do themselves. The impersonation floor is
36 * `edit_docs`, not `manage_options` (ADR-006): BetterDocs' capability model has
37 * real non-admin API users.
38 *
39 * A grant whose user no longer qualifies is **dead, not absent**: it answers
40 * `403` with a typed `capability_missing`, never a bare `401`. The difference
41 * matters to a client — a 401 says "authenticate", and an OAuth client would
42 * loop through the whole flow again to arrive at the same place.
43 *
44 * An unauthenticated call gets `401` plus the RFC 9728 `WWW-Authenticate`
45 * challenge pointing at {@see MCPOAuth::resource_metadata_url()} — the REST
46 * alias, so hosts that intercept `/.well-known/` still work (ADR-014). A request
47 * with **no** credential never counts against the rate limiter: that is the
48 * opening move of the OAuth flow, not a guess.
49 *
50 * @since 4.9.0
51 */
52 final class MCPServer {
53
54 /**
55 * MCP protocol version this server implements.
56 *
57 * @since 4.9.0
58 */
59 const PROTOCOL_VERSION = '2025-06-18';
60
61 /**
62 * The capability a credential's user must still hold for the grant to be
63 * alive (ADR-006). Deliberately not `manage_options`: BetterDocs' capability
64 * model has real non-admin API users. Every ability then re-checks its own,
65 * finer capability on top of this floor.
66 *
67 * @since 4.9.0
68 */
69 const IMPERSONATION_CAPABILITY = 'edit_docs';
70
71 /**
72 * JSON-RPC error codes. The last two are in the implementation-defined
73 * `-32000..-32099` range.
74 *
75 * @since 4.9.0
76 */
77 const PARSE_ERROR = -32700;
78 const INVALID_REQUEST = -32600;
79 const METHOD_NOT_FOUND = -32601;
80 const INVALID_PARAMS = -32602;
81 const DISABLED = -32000;
82 const UNAUTHORIZED = -32001;
83
84 /**
85 * Handle one MCP HTTP request.
86 *
87 * Reads the JSON-RPC message from the request body, authenticates, and
88 * dispatches. Notifications get a bodyless 202.
89 *
90 * @since 4.9.0
91 *
92 * @param \WP_REST_Request $request Incoming request (raw body).
93 * @return \WP_REST_Response
94 */
95 public static function handle( $request ) {
96 // A previous request in this process may have left an override behind;
97 // this one's credential is the only thing that may decide it.
98 MCPTools::set_read_only_override( null );
99
100 self::debug_tap( $request );
101
102 // The admin toggle is the master switch: off means no MCP surface at
103 // all. It never gates ability registration or /mcp/health (ADR-013).
104 if ( ! self::is_enabled() ) {
105 return self::decorate(
106 self::error_response(
107 null,
108 self::DISABLED,
109 __( 'MCP is disabled on this site. Enable it under BetterDocs → MCP.', 'betterdocs' ),
110 403
111 )
112 );
113 }
114
115 $presented = self::extract_token( $request );
116
117 // Lockout first, so a rate-limited IP never reaches the compare. Only a
118 // client that actually presented something can be locked out.
119 if ( '' !== $presented && MCPRateLimiter::is_locked() ) {
120 $response = self::error_response(
121 null,
122 self::UNAUTHORIZED,
123 __( 'Too many failed attempts. Try again later.', 'betterdocs' ),
124 429
125 );
126
127 // Keep the challenge on the 429 as well: a client that only ever
128 // sees a bare 429 concludes the server has no OAuth at all.
129 $response->header( 'WWW-Authenticate', self::challenge_header() );
130 $response->header( 'Retry-After', (string) MCPRateLimiter::retry_after() );
131
132 return self::decorate( $response );
133 }
134
135 $user_id = self::authorize( $presented );
136
137 if ( null === $user_id ) {
138 if ( '' !== $presented ) {
139 MCPRateLimiter::record_failure();
140 }
141
142 $response = self::error_response(
143 null,
144 self::UNAUTHORIZED,
145 __( 'Unauthorized: invalid or missing connection token.', 'betterdocs' ),
146 401
147 );
148
149 // RFC 9728: point OAuth-capable clients at the protected-resource
150 // metadata so they can start the flow.
151 $response->header( 'WWW-Authenticate', self::challenge_header() );
152
153 return self::decorate( $response );
154 }
155
156 if ( ! self::impersonate( $user_id ) ) {
157 // The credential is genuine; the user behind it is gone or no
158 // longer allowed. That is a dead grant, not a missing one.
159 return self::decorate( self::dead_grant_response() );
160 }
161
162 MCPRateLimiter::clear();
163
164 $raw = (string) $request->get_body();
165 $msg = json_decode( $raw, true );
166
167 if ( null === $msg && JSON_ERROR_NONE !== json_last_error() ) {
168 return self::decorate(
169 self::error_response( null, self::PARSE_ERROR, __( 'Parse error: body is not valid JSON.', 'betterdocs' ), 400 )
170 );
171 }
172
173 // A batch is an array of messages. Answer each one; per JSON-RPC, drop
174 // the notifications from the reply.
175 if ( is_array( $msg ) && array_key_exists( 0, $msg ) ) {
176 $responses = [];
177
178 foreach ( $msg as $one ) {
179 $answer = self::dispatch( is_array( $one ) ? $one : [] );
180
181 if ( null !== $answer ) {
182 $responses[] = $answer;
183 }
184 }
185
186 if ( empty( $responses ) ) {
187 return self::decorate( new \WP_REST_Response( null, 202 ) );
188 }
189
190 return self::decorate( new \WP_REST_Response( $responses, 200 ) );
191 }
192
193 if ( ! is_array( $msg ) ) {
194 return self::decorate(
195 self::error_response( null, self::INVALID_REQUEST, __( 'Invalid request.', 'betterdocs' ), 400 )
196 );
197 }
198
199 $response = self::dispatch( $msg );
200
201 if ( null === $response ) {
202 // A notification — acknowledged, with no body.
203 return self::decorate( new \WP_REST_Response( null, 202 ) );
204 }
205
206 return self::decorate( new \WP_REST_Response( $response, 200 ) );
207 }
208
209 /**
210 * Whether the MCP endpoint is switched on.
211 *
212 * `MCPManager` owns the toggle and its filter; the setting is also read
213 * directly here, so this class is testable and measurable on its own. The two must agree — `MCPManager::is_enabled()` reads the same key.
214 *
215 * @since 4.9.0
216 *
217 * @return bool
218 */
219 private static function is_enabled() {
220 if ( class_exists( __NAMESPACE__ . '\\MCPManager' ) && method_exists( __NAMESPACE__ . '\\MCPManager', 'is_enabled' ) ) {
221 return (bool) MCPManager::is_enabled();
222 }
223
224 if ( ! function_exists( 'betterdocs' ) ) {
225 return false;
226 }
227
228 $plugin = betterdocs();
229
230 if ( ! is_object( $plugin ) || ! isset( $plugin->settings ) || ! is_object( $plugin->settings ) ) {
231 return false;
232 }
233
234 return ! empty( $plugin->settings->get( 'enable_mcp' ) );
235 }
236
237 /**
238 * Dispatch one JSON-RPC message.
239 *
240 * @since 4.9.0
241 *
242 * @param array $msg Decoded JSON-RPC message.
243 * @return array|null The response envelope, or null for a notification.
244 */
245 private static function dispatch( array $msg ) {
246 $method = isset( $msg['method'] ) ? (string) $msg['method'] : '';
247 $id = isset( $msg['id'] ) ? $msg['id'] : null;
248 $params = isset( $msg['params'] ) && is_array( $msg['params'] ) ? $msg['params'] : [];
249
250 // A message with no `id` is a notification: acknowledged, never answered.
251 $is_notification = ! array_key_exists( 'id', $msg );
252
253 switch ( $method ) {
254 case 'initialize':
255 return self::result(
256 $id,
257 [
258 'protocolVersion' => self::PROTOCOL_VERSION,
259 'capabilities' => [
260 'tools' => [ 'listChanged' => false ]
261 ],
262 'serverInfo' => [
263 'name' => 'betterdocs',
264 'version' => defined( 'BETTERDOCS_VERSION' ) ? BETTERDOCS_VERSION : '0.0.0'
265 ],
266 'instructions' => self::instructions()
267 ]
268 );
269
270 case 'ping':
271 return self::result( $id, (object) [] );
272
273 case 'tools/list':
274 $tools = MCPTools::list();
275
276 // An empty catalog while MCP is enabled means the Abilities
277 // runtime never loaded, and the client sees a clean, useless
278 // connection. Leave a trail for whoever debugs it; the health
279 // report carries the loud version.
280 if ( empty( $tools ) && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
281 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-gated diagnostic.
282 error_log( '[BD-MCP] tools/list returned 0 tools. ' . AbilitiesRegistrar::summary() );
283 }
284
285 return self::result( $id, [ 'tools' => $tools ] );
286
287 case 'tools/call':
288 return self::call_tool( $id, $params );
289
290 default:
291 if ( $is_notification || 0 === strpos( $method, 'notifications/' ) ) {
292 return null;
293 }
294
295 return self::error( $id, self::METHOD_NOT_FOUND, 'Method not found: ' . $method );
296 }
297 }
298
299 /**
300 * The `instructions` string handed to the client on `initialize`.
301 *
302 * One paragraph, because clients put it straight into the model's context.
303 *
304 * @since 4.9.0
305 *
306 * @return string
307 */
308 private static function instructions() {
309 return __( 'Call bd-get-status first: it reports the BetterDocs and BetterDocs Pro versions, which capabilities the connected user holds, and which features are switched on, so you can tell a refusal apart from a misconfiguration before you try anything. Every tool describes its own availability — a tool that needs BetterDocs Pro, or a setting that is currently off, says so in its description and returns a typed error explaining what would make it work. Prefer names over ids where a tool accepts both; it will find or create the matching term.', 'betterdocs' );
310 }
311
312 /**
313 * Run a `tools/call` and wrap the answer as MCP content.
314 *
315 * A tool-level failure is a *successful* JSON-RPC response carrying
316 * `isError: true` (per MCP), so the model reads the typed object instead of
317 * the transport swallowing it. The same object is sent twice: as JSON text,
318 * which every client renders, and as `structuredContent`, which the clients
319 * that understand it can act on (ADR-016).
320 *
321 * @since 4.9.0
322 *
323 * @param mixed $id JSON-RPC id.
324 * @param array $params `{ name: string, arguments: array }`.
325 * @return array
326 */
327 private static function call_tool( $id, array $params ) {
328 $name = isset( $params['name'] ) ? (string) $params['name'] : '';
329 $args = isset( $params['arguments'] ) && is_array( $params['arguments'] ) ? $params['arguments'] : [];
330
331 if ( '' === $name ) {
332 return self::error( $id, self::INVALID_PARAMS, __( 'Missing tool name.', 'betterdocs' ) );
333 }
334
335 $result = MCPTools::invoke( $name, $args );
336
337 if ( is_wp_error( $result ) ) {
338 return self::result( $id, self::content( self::error_payload( $result ), true ) );
339 }
340
341 return self::result( $id, self::content( $result, false ) );
342 }
343
344 /**
345 * The typed object carried by a `WP_Error` from the ability layer.
346 *
347 * `AbilityError` always puts `error` and `message` in the data, but a
348 * `WP_Error` from anywhere else may not, so both are filled in from the code
349 * and the message when they are missing.
350 *
351 * @since 4.9.0
352 *
353 * @param \WP_Error $error The error.
354 * @return array
355 */
356 private static function error_payload( \WP_Error $error ) {
357 $data = $error->get_error_data();
358
359 if ( ! is_array( $data ) ) {
360 $data = [];
361 }
362
363 if ( ! isset( $data['error'] ) ) {
364 $data['error'] = (string) $error->get_error_code();
365 }
366
367 if ( ! isset( $data['message'] ) ) {
368 $data['message'] = (string) $error->get_error_message();
369 }
370
371 return $data;
372 }
373
374 /**
375 * Wrap a payload as an MCP tool result.
376 *
377 * @since 4.9.0
378 *
379 * @param array $payload Result or typed error object.
380 * @param bool $is_error Whether this is a tool-level failure.
381 * @return array
382 */
383 private static function content( array $payload, $is_error ) {
384 return [
385 'content' => [
386 [
387 'type' => 'text',
388 'text' => wp_json_encode( $payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES )
389 ]
390 ],
391 'structuredContent' => $payload,
392 'isError' => (bool) $is_error
393 ];
394 }
395
396 /**
397 * Validate the presented Bearer credential.
398 *
399 * Pairing token first, then OAuth. Each path sets the read-only override
400 * from its own grant, so the two can disagree without one leaking into the
401 * other.
402 *
403 * @since 4.9.0
404 *
405 * @param string $presented Credential from the request.
406 * @return int|null The granting user's id, or null when nothing matched.
407 */
408 private static function authorize( $presented ) {
409 $presented = (string) $presented;
410
411 if ( '' === $presented ) {
412 return null;
413 }
414
415 // Path 1: the static per-site pairing token. Compared through
416 // verify_token(), which checks the stored hash — the token is encrypted
417 // at rest and never compared in the clear (ADR-007).
418 if ( MCPPairing::verify_token( $presented ) ) {
419 MCPTools::set_read_only_override( MCPPairing::is_read_only() );
420 MCPPairing::touch_last_used();
421
422 return MCPPairing::user_id();
423 }
424
425 // Path 2: an OAuth 2.1 access token. Its own granted scope decides
426 // read-only, independent of the pairing token's scopes.
427 $grant = MCPOAuth::validate_token( $presented );
428
429 if ( is_array( $grant ) ) {
430 MCPTools::set_read_only_override( MCPOAuth::scope_is_read_only( $grant['scope'] ) );
431
432 return (int) $grant['user_id'];
433 }
434
435 return null;
436 }
437
438 /**
439 * Run the request as the user who granted the credential.
440 *
441 * Refuses when that user no longer exists or no longer holds `edit_docs` —
442 * a deleted or demoted user's grants die with them (ADR-006). Every
443 * ability then re-checks its own capability on top of this floor.
444 *
445 * @since 4.9.0
446 *
447 * @param int $user_id Granting user id.
448 * @return bool
449 */
450 private static function impersonate( $user_id ) {
451 $user_id = (int) $user_id;
452
453 if ( $user_id <= 0 ) {
454 return false;
455 }
456
457 $user = get_user_by( 'id', $user_id );
458
459 if ( ! $user || ! user_can( $user, self::IMPERSONATION_CAPABILITY ) ) {
460 return false;
461 }
462
463 wp_set_current_user( $user_id );
464
465 return true;
466 }
467
468 /**
469 * The 403 for a credential whose user cannot be impersonated.
470 *
471 * Carries the typed object in `error.data` so a client gets the same
472 * vocabulary here as it would from a tool (ADR-016).
473 *
474 * @since 4.9.0
475 *
476 * @return \WP_REST_Response
477 */
478 private static function dead_grant_response() {
479 $typed = AbilityError::capability_missing(
480 self::IMPERSONATION_CAPABILITY,
481 __( 'use this MCP connection', 'betterdocs' )
482 );
483
484 $payload = self::error_payload( $typed );
485
486 $envelope = self::error( null, self::UNAUTHORIZED, $payload['message'] );
487 $envelope['error']['data'] = $payload;
488
489 return new \WP_REST_Response( $envelope, 403 );
490 }
491
492 /**
493 * The RFC 9728 `WWW-Authenticate` challenge value.
494 *
495 * Points at the REST alias rather than `/.well-known/…`, because a host that
496 * intercepts well-known paths would otherwise send the client somewhere that
497 * is not us (ADR-014); the alias is filterable.
498 *
499 * @since 4.9.0
500 *
501 * @return string
502 */
503 private static function challenge_header() {
504 return sprintf( 'Bearer resource_metadata="%s"', MCPOAuth::resource_metadata_url() );
505 }
506
507 /**
508 * Pull the token from `Authorization: Bearer …`.
509 *
510 * `MCPManager` sets this header synthetically when the token arrived
511 * as a path segment of the pretty endpoint, so there is one place that reads
512 * a credential.
513 *
514 * @since 4.9.0
515 *
516 * @param \WP_REST_Request $request Incoming request.
517 * @return string
518 */
519 private static function extract_token( $request ) {
520 $auth = $request->get_header( 'authorization' );
521
522 if ( is_string( $auth ) && preg_match( '/^Bearer\s+(.+)$/i', trim( $auth ), $matches ) ) {
523 return trim( $matches[1] );
524 }
525
526 return '';
527 }
528
529 /**
530 * Headers every MCP response carries.
531 *
532 * `Cache-Control: no-store, private` is not optional: the pretty endpoint
533 * can carry the pairing token in its path, so a shared cache or proxy
534 * holding a response keyed on that URL would keep an admin-equivalent
535 * credential in its store (ADR-007).
536 *
537 * @since 4.9.0
538 *
539 * @param \WP_REST_Response $response Response to decorate.
540 * @return \WP_REST_Response
541 */
542 private static function decorate( $response ) {
543 $response->header( 'MCP-Protocol-Version', self::PROTOCOL_VERSION );
544 $response->header( 'Cache-Control', 'no-store, private' );
545 $response->header( 'Content-Type', 'application/json' );
546
547 return $response;
548 }
549
550 /**
551 * Opt-in diagnostic tap: define `BETTERDOCS_MCP_DEBUG` in `wp-config.php`.
552 *
553 * Logs the method, whether a credential was presented, and the tool name —
554 * never the credential, never the parameters, which routinely carry document
555 * content.
556 *
557 * @since 4.9.0
558 *
559 * @param \WP_REST_Request $request Incoming request.
560 * @return void
561 */
562 private static function debug_tap( $request ) {
563 if ( ! defined( 'BETTERDOCS_MCP_DEBUG' ) || ! BETTERDOCS_MCP_DEBUG ) {
564 return;
565 }
566
567 $msg = json_decode( (string) $request->get_body(), true );
568 $method = is_array( $msg ) && isset( $msg['method'] ) ? (string) $msg['method'] : '?';
569 $tool = is_array( $msg ) && isset( $msg['params']['name'] ) ? (string) $msg['params']['name'] : '-';
570
571 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- opt-in debug tap.
572 error_log(
573 sprintf(
574 '[BD-MCP] in method=%s tool=%s auth=%s',
575 $method,
576 $tool,
577 $request->get_header( 'authorization' ) ? 'yes' : 'no'
578 )
579 );
580 }
581
582 /**
583 * Build a JSON-RPC success envelope.
584 *
585 * @since 4.9.0
586 *
587 * @param mixed $id JSON-RPC id.
588 * @param mixed $result Result payload.
589 * @return array
590 */
591 private static function result( $id, $result ) {
592 return [
593 'jsonrpc' => '2.0',
594 'id' => $id,
595 'result' => $result
596 ];
597 }
598
599 /**
600 * Build a JSON-RPC error envelope.
601 *
602 * @since 4.9.0
603 *
604 * @param mixed $id JSON-RPC id.
605 * @param int $code JSON-RPC error code.
606 * @param string $message Error message.
607 * @return array
608 */
609 private static function error( $id, $code, $message ) {
610 return [
611 'jsonrpc' => '2.0',
612 'id' => $id,
613 'error' => [
614 'code' => (int) $code,
615 'message' => (string) $message
616 ]
617 ];
618 }
619
620 /**
621 * Build a transport-level error response with an HTTP status.
622 *
623 * @since 4.9.0
624 *
625 * @param mixed $id JSON-RPC id.
626 * @param int $code JSON-RPC error code.
627 * @param string $message Error message.
628 * @param int $http HTTP status.
629 * @return \WP_REST_Response
630 */
631 private static function error_response( $id, $code, $message, $http ) {
632 return new \WP_REST_Response( self::error( $id, $code, $message ), (int) $http );
633 }
634 }
635