PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.3
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.3
4.9.3 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 All 201 releases
betterdocs / includes / Mcp / MCPPairing.php

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

682 lines 20.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP pairing lifecycle — mint / rotate / revoke the per-site connection token.
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\Core\SecretAtRest;
16
17 /**
18 * The "paste one string and you are connected" path, for the clients that do
19 * not speak OAuth: an administrator clicks Connect, this class mints a 32-byte
20 * secret, and the user pastes either the connect URL (token in the path) or the
21 * endpoint plus a Bearer header into their AI client. `MCPServer` validates it
22 * directly — no hosted infrastructure is involved.
23 *
24 * The token is admin-equivalent and never expires: `MCPServer` runs the call as
25 * the user who minted it, so this one string reaches every write tool. It is
26 * therefore stored as two separate values (ADR-007). The plaintext copy is never
27 * the credential of record:
28 *
29 * - `token_hash` — SHA-256, and the only thing authentication compares.
30 * - `site_token` — the same token under {@see SecretAtRest}, kept only so the
31 * UI, the config snippets, the AI prompt and the self-test can show it.
32 *
33 * Separating them is the point. Verifying against the hash rather than a
34 * decrypted copy means a rotated auth salt — which makes the ciphertext
35 * unrecoverable — does not lock out clients already holding a valid token; they
36 * keep authenticating, and the UI reports the value as unavailable until an
37 * administrator rotates it.
38 *
39 * A row written before this (plaintext, no hash) verifies once against the
40 * plaintext and is upgraded in place, so nobody has to re-pair.
41 *
42 * State lives in the non-autoloaded option `betterdocs_mcp_pairing`:
43 *
44 * {
45 * site_token: string ciphertext (`bdenc:v1:…`) of the secret,
46 * token_hash: string sha256 of the secret — the authenticator,
47 * connected: bool,
48 * connected_at: int unix ts,
49 * scopes: string[] e.g. ['read','write'],
50 * user_id: int who minted it; MCP calls run as them,
51 * last_used: int throttled to one write a minute
52 * }
53 *
54 * @since 4.9.0
55 */
56 final class MCPPairing {
57
58 /**
59 * Option key holding all MCP pairing state.
60 *
61 * @since 4.9.0
62 */
63 const OPTION = 'betterdocs_mcp_pairing';
64
65 /**
66 * Path segment of the pretty per-site endpoint.
67 *
68 * @since 4.9.0
69 */
70 const SITE_ENDPOINT_PATH = 'betterdocs/mcp';
71
72 /**
73 * Scopes granted on connect unless read-only was asked for.
74 *
75 * @since 4.9.0
76 */
77 const DEFAULT_SCOPES = [ 'read', 'write' ];
78
79 /**
80 * Throttle window, in seconds, for `last_used` writes — at most one option
81 * write a minute, so a busy client cannot turn every call into a database
82 * write.
83 *
84 * @since 4.9.0
85 */
86 const LAST_USED_THROTTLE = 60;
87
88 /**
89 * The primary endpoint the user pastes into their AI client.
90 *
91 * @since 4.9.0
92 *
93 * @return string
94 */
95 public static function site_endpoint() {
96 return home_url( '/' . self::SITE_ENDPOINT_PATH );
97 }
98
99 /**
100 * Always-on fallback endpoint under the REST namespace, for hosts where the
101 * pretty rewrite cannot be served (plain permalinks, for instance).
102 *
103 * @since 4.9.0
104 *
105 * @return string
106 */
107 public static function site_endpoint_fallback() {
108 return rest_url( 'betterdocs/v1/mcp' );
109 }
110
111 /**
112 * The single URL that carries the token in its path. Empty when not
113 * connected, or when the display copy cannot be decrypted.
114 *
115 * @since 4.9.0
116 *
117 * @return string
118 */
119 public static function connect_url() {
120 $token = self::site_token();
121
122 if ( '' === $token ) {
123 return '';
124 }
125
126 return self::site_endpoint() . '/' . $token;
127 }
128
129 /**
130 * Current pairing state, defaults merged.
131 *
132 * @since 4.9.0
133 *
134 * @return array {
135 * @type string $site_token Decrypted token, '' when unrecoverable.
136 * @type string $token_hash SHA-256 verifier.
137 * @type bool $connected Whether a pairing is active.
138 * @type int $connected_at Unix timestamp of the first connect.
139 * @type string[] $scopes Granted scopes.
140 * @type int $user_id User the connection runs as.
141 * @type int $last_used Unix timestamp of the last MCP call.
142 * }
143 */
144 public static function state() {
145 $stored = get_option( self::OPTION, [] );
146
147 if ( ! is_array( $stored ) ) {
148 $stored = [];
149 }
150
151 $raw = isset( $stored['site_token'] ) ? (string) $stored['site_token'] : '';
152
153 return [
154 // Decrypted for display and for the self-test's own probe. Stored
155 // encrypted (ADR-007) — a database read on its own no longer yields
156 // a usable, admin-equivalent credential.
157 'site_token' => '' === $raw ? '' : SecretAtRest::decrypt( $raw ),
158 // What verify_token() compares against. Held separately so a token
159 // whose ciphertext can no longer be opened keeps authenticating the
160 // clients already configured with it.
161 'token_hash' => isset( $stored['token_hash'] ) ? (string) $stored['token_hash'] : '',
162 'connected' => ! empty( $stored['connected'] ),
163 'connected_at' => isset( $stored['connected_at'] ) ? (int) $stored['connected_at'] : 0,
164 'scopes' => isset( $stored['scopes'] ) && is_array( $stored['scopes'] )
165 ? array_values( array_map( 'strval', $stored['scopes'] ) )
166 : [],
167 'user_id' => isset( $stored['user_id'] ) ? (int) $stored['user_id'] : 0,
168 'last_used' => isset( $stored['last_used'] ) ? (int) $stored['last_used'] : 0
169 ];
170 }
171
172 /**
173 * Whether a presented token is this site's pairing token.
174 *
175 * Compared against the stored hash in constant time. A row written before
176 * ADR-007 holds a plaintext token and no hash: it is verified against the
177 * plaintext once and then upgraded in place.
178 *
179 * @since 4.9.0
180 *
181 * @param string $presented Token presented by the client.
182 * @return bool
183 */
184 public static function verify_token( $presented ) {
185 $presented = (string) $presented;
186
187 if ( '' === $presented ) {
188 return false;
189 }
190
191 $state = self::state();
192
193 if ( '' !== $state['token_hash'] ) {
194 return hash_equals( $state['token_hash'], self::hash( $presented ) );
195 }
196
197 // Legacy row: plaintext, no hash.
198 if ( '' === $state['site_token'] || ! hash_equals( $state['site_token'], $presented ) ) {
199 return false;
200 }
201
202 self::upgrade_legacy_storage( $presented );
203
204 return true;
205 }
206
207 /**
208 * Record that the pairing token was just used to authenticate an MCP call.
209 *
210 * Throttled to at most one option write per `LAST_USED_THROTTLE` seconds.
211 * No-op when nothing is stored.
212 *
213 * @since 4.9.0
214 *
215 * @return void
216 */
217 public static function touch_last_used() {
218 $stored = get_option( self::OPTION, [] );
219
220 if ( ! is_array( $stored ) || empty( $stored['site_token'] ) ) {
221 return;
222 }
223
224 $now = time();
225 $last = isset( $stored['last_used'] ) ? (int) $stored['last_used'] : 0;
226
227 if ( $now - $last < self::LAST_USED_THROTTLE ) {
228 return;
229 }
230
231 $stored['last_used'] = $now;
232
233 update_option( self::OPTION, $stored, false );
234 }
235
236 /**
237 * The pairing token in the clear, for display only.
238 *
239 * Empty when not connected, and empty when the ciphertext will not open —
240 * the salt was rotated, or the row was migrated without `wp-config.php`.
241 * Never use this to authenticate; use {@see self::verify_token()}.
242 *
243 * @since 4.9.0
244 *
245 * @return string
246 */
247 public static function site_token() {
248 return self::state()['site_token'];
249 }
250
251 /**
252 * The user the connection runs as (the token's minter).
253 *
254 * @since 4.9.0
255 *
256 * @return int
257 */
258 public static function user_id() {
259 return self::state()['user_id'];
260 }
261
262 /**
263 * Whether a pairing token is active for this site.
264 *
265 * Deliberately asks the *stored* credential, not the decrypted display
266 * copy: a site whose auth salt changed still has a working pairing.
267 *
268 * @since 4.9.0
269 *
270 * @return bool
271 */
272 public static function is_connected() {
273 $state = self::state();
274
275 return $state['connected'] && ( '' !== $state['token_hash'] || '' !== $state['site_token'] );
276 }
277
278 /**
279 * Whether the active pairing is limited to read-only tools.
280 *
281 * **False when nothing is paired.** An absent pairing is not a read-only
282 * pairing: `MCPTools::is_read_only()` falls back here only when no OAuth
283 * scope override is set, which on a real request means the pairing path,
284 * where a pairing exists by definition. Answering `true` for an unpaired
285 * site would silently hide every write tool from `tools/list` in the admin
286 * UI, the health report and wp-cli.
287 *
288 * @since 4.9.0
289 *
290 * @return bool
291 */
292 public static function is_read_only() {
293 if ( ! self::is_connected() ) {
294 return false;
295 }
296
297 return ! in_array( 'write', self::state()['scopes'], true );
298 }
299
300 /**
301 * Sanitised snapshot for the MCP admin page.
302 *
303 * @since 4.9.0
304 *
305 * @return array
306 */
307 public static function public_status() {
308 $state = self::state();
309
310 return [
311 'connected' => self::is_connected(),
312 'connection_token' => $state['site_token'],
313 // Distinguishes "not connected" from "connected, but the display
314 // copy cannot be decrypted" — the salt-rotation case, where the
315 // pairing still authenticates and the UI must offer a rotate
316 // rather than claim there is no connection.
317 'token_available' => '' !== $state['site_token'],
318 'connect_url' => self::connect_url(),
319 'mcp_endpoint' => self::site_endpoint(),
320 'mcp_endpoint_rest' => self::site_endpoint_fallback(),
321 'connected_at' => $state['connected_at'],
322 'last_used' => $state['last_used'],
323 'scopes' => $state['scopes'],
324 'read_only' => self::is_read_only(),
325 // Ready-to-paste connection recipes (header-based — the secret
326 // stays out of URLs, so it cannot leak into server or proxy logs).
327 'config' => self::config_snippets(),
328 // A drop-in instruction the user can paste into their AI client so
329 // it sets the connection up itself. The default is token-less and
330 // leaves the client to walk the OAuth flow; the second carries the
331 // connection token, for a machine with no browser (ADR-055).
332 'ai_prompt' => self::ai_prompt(),
333 'ai_prompt_token' => self::ai_prompt_token()
334 ];
335 }
336
337 /**
338 * Ready-to-paste connection recipes for the dashboard, both header-based.
339 * Empty strings when the token is unavailable.
340 *
341 * @since 4.9.0
342 *
343 * @return array {
344 * @type string $cli Claude Code one-liner.
345 * @type string $json Portable `mcpServers` block.
346 * }
347 */
348 public static function config_snippets() {
349 $token = self::site_token();
350
351 if ( '' === $token ) {
352 return [
353 'cli' => '',
354 'json' => ''
355 ];
356 }
357
358 $endpoint = self::site_endpoint();
359
360 // Claude Code one-liner. The CLI requires the positional NAME and URL
361 // BEFORE any flags (`claude mcp add <name> <url> --flags`).
362 $cli = sprintf(
363 'claude mcp add betterdocs %s --transport http --header "Authorization: Bearer %s"',
364 $endpoint,
365 $token
366 );
367
368 // Portable mcpServers JSON block (Claude Desktop and other clients).
369 $json = wp_json_encode(
370 [
371 'mcpServers' => [
372 'betterdocs' => [
373 'url' => $endpoint,
374 'headers' => [
375 'Authorization' => 'Bearer ' . $token
376 ]
377 ]
378 ]
379 ],
380 JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
381 );
382
383 return [
384 'cli' => $cli,
385 'json' => is_string( $json ) ? $json : ''
386 ];
387 }
388
389 /**
390 * A copy-paste instruction the user hands to their AI assistant so *it*
391 * sets the connection up. Empty when not connected.
392 *
393 * **Carries no credential.** The endpoint answers an unauthenticated call
394 * with `401` + `WWW-Authenticate: Bearer resource_metadata=…`, which is the
395 * challenge that starts the OAuth flow, so an assistant needs the URL and
396 * nothing else (ADR-054/ADR-055). {@see self::ai_prompt_token()} is the
397 * variant for a machine with no browser to approve in.
398 *
399 * @since 4.9.0
400 *
401 * @return string
402 */
403 public static function ai_prompt() {
404 if ( ! self::is_connected() ) {
405 return '';
406 }
407
408 $endpoint = self::site_endpoint();
409
410 $lines = [
411 'Add the following remote MCP server to your connections so you can manage the documentation on my WordPress site, then use it.',
412 '',
413 'Server name: BetterDocs',
414 'Server URL: ' . $endpoint,
415 'Transport: streamable HTTP',
416 'Authentication: OAuth — when you first connect, the server answers with a sign-in link; open it, sign in to the WordPress site and approve.',
417 'Access level: as the approving user.',
418 '',
419 'If you use the Claude Code CLI, this is the exact command:',
420 ' claude mcp add --transport http betterdocs ' . $endpoint,
421 'Then run /mcp and choose BetterDocs to approve.',
422 '',
423 'Add it now, confirm it is connected by calling its "bd-list-docs" tool, and tell me which docs you can see.'
424 ];
425
426 $prompt = implode( "\n", $lines );
427
428 /**
429 * Filter the copy-paste AI setup prompt shown on the MCP page.
430 *
431 * @since 4.9.0
432 *
433 * @param string $prompt The default prompt text.
434 * @param bool $read_only Whether the connection is read-only.
435 */
436 return (string) apply_filters( 'betterdocs_mcp_ai_prompt', $prompt, self::is_read_only() );
437 }
438
439 /**
440 * The same instruction with the connection token in it, for a machine that
441 * cannot open a browser to approve an OAuth grant.
442 *
443 * It lives behind the MCP page's
444 * "No browser on that machine?" disclosure and is never rendered until
445 * someone opens it (ADR-055) — the read-only wording stays here rather than
446 * in {@see self::ai_prompt()} because scope is a property of a *pairing
447 * token*; an OAuth grant acts as the user who approved it. Empty when not
448 * connected, or when the stored token cannot be decrypted.
449 *
450 * @since 4.9.0
451 *
452 * @return string
453 */
454 public static function ai_prompt_token() {
455 if ( ! self::is_connected() ) {
456 return '';
457 }
458
459 $token = self::site_token();
460
461 if ( '' === $token ) {
462 return '';
463 }
464
465 $endpoint = self::site_endpoint();
466 $access = self::is_read_only()
467 ? 'read-only (read docs, FAQs, categories, settings and analytics)'
468 : 'read-write (can create and update docs, FAQs, categories and settings)';
469
470 $lines = [
471 'Add the following remote MCP server to your connections so you can manage the documentation on my WordPress site, then use it.',
472 '',
473 'Server name: BetterDocs',
474 'Server URL: ' . $endpoint,
475 'Transport: streamable HTTP',
476 'Authentication: Bearer token (in the Authorization header)',
477 'API key: ' . $token,
478 'Access level: ' . $access,
479 '',
480 'If you use the Claude Code CLI, this is the exact command (name and URL come BEFORE the flags):',
481 ' ' . self::config_snippets()['cli'],
482 '',
483 'Add it now, confirm it is connected by calling its "bd-list-docs" tool, and tell me which docs you can see.'
484 ];
485
486 $prompt = implode( "\n", $lines );
487
488 /**
489 * Filter the token-bearing variant of the AI setup prompt.
490 *
491 * Deliberately a second filter rather than a third argument on
492 * `betterdocs_mcp_ai_prompt`: a callback already registered against that
493 * filter would otherwise rewrite both texts at once, and the two say
494 * different things about authentication (ADR-055).
495 *
496 * @since 4.9.0
497 *
498 * @param string $prompt The default token-bearing prompt text.
499 * @param bool $read_only Whether the connection is read-only.
500 */
501 return (string) apply_filters( 'betterdocs_mcp_ai_prompt_token', $prompt, self::is_read_only() );
502 }
503
504 /**
505 * Connect — mint a connection token for this site's MCP endpoint.
506 *
507 * Idempotent: re-connecting keeps the existing token and its scopes, so a
508 * paired client is not silently broken. Use {@see self::rotate()} to change
509 * either.
510 *
511 * @since 4.9.0
512 *
513 * @param bool $read_only Grant only the `read` scope on a NEW token.
514 * @return array Public status.
515 */
516 public static function connect( $read_only = false ) {
517 $state = self::state();
518
519 if ( '' !== $state['token_hash'] || '' !== $state['site_token'] ) {
520 $stored = get_option( self::OPTION, [] );
521
522 if ( ! is_array( $stored ) ) {
523 $stored = [];
524 }
525
526 // The credential itself is left exactly as stored — including a
527 // ciphertext this site can no longer open, whose hash still
528 // authenticates every configured client. Minting a replacement is
529 // what rotate() is for, never a side effect of pressing Connect.
530 $stored['connected'] = true;
531 $stored['connected_at'] = $state['connected_at'] ? $state['connected_at'] : time();
532 $stored['scopes'] = ! empty( $state['scopes'] )
533 ? $state['scopes']
534 : self::scopes_for( (bool) $read_only );
535 $stored['user_id'] = $state['user_id'] ? $state['user_id'] : get_current_user_id();
536
537 update_option( self::OPTION, $stored, false );
538
539 return self::public_status();
540 }
541
542 $token = self::mint_token();
543
544 update_option(
545 self::OPTION,
546 [
547 'site_token' => SecretAtRest::encrypt( $token ),
548 'token_hash' => self::hash( $token ),
549 'connected' => true,
550 'connected_at' => time(),
551 'scopes' => self::scopes_for( (bool) $read_only ),
552 'user_id' => get_current_user_id(),
553 'last_used' => 0
554 ],
555 false
556 );
557
558 return self::public_status();
559 }
560
561 /**
562 * Rotate — mint a brand-new token, invalidating the previous one
563 * immediately. The leaked-token remedy; optionally flips read-only.
564 *
565 * @since 4.9.0
566 *
567 * @param bool|null $read_only Null keeps the current scopes; true/false sets them.
568 * @return array Public status, with the fresh token.
569 */
570 public static function rotate( $read_only = null ) {
571 $state = self::state();
572 $scopes = null === $read_only
573 ? ( ! empty( $state['scopes'] ) ? $state['scopes'] : self::DEFAULT_SCOPES )
574 : self::scopes_for( (bool) $read_only );
575
576 $token = self::mint_token();
577
578 update_option(
579 self::OPTION,
580 [
581 'site_token' => SecretAtRest::encrypt( $token ),
582 'token_hash' => self::hash( $token ),
583 'connected' => true,
584 'connected_at' => time(),
585 'scopes' => $scopes,
586 'user_id' => get_current_user_id() ? get_current_user_id() : $state['user_id'],
587 'last_used' => 0
588 ],
589 false
590 );
591
592 return self::public_status();
593 }
594
595 /**
596 * Disconnect — revoke the pairing token and, by default, every OAuth grant,
597 * so the admin page's Disconnect button is a single kill switch for all MCP
598 * access.
599 *
600 * `$revoke_oauth` exists for the one caller that must not be a kill switch:
601 * {@see MCPGrants::disconnect_pairing_of()} fires from `deleted_user` and
602 * has already revoked *that* user's own grants with
603 * `MCPOAuth::revoke_user()`. Deleting one account must not disconnect every
604 * other person's AI client from the site (ADR-042).
605 *
606 * @since 4.9.0
607 *
608 * @param bool $revoke_oauth Whether to revoke every OAuth grant as well.
609 * Default true — the kill-switch behaviour the
610 * admin page and the REST route rely on.
611 * @return array Public status after disconnecting.
612 */
613 public static function disconnect( $revoke_oauth = true ) {
614 delete_option( self::OPTION );
615
616 if ( $revoke_oauth ) {
617 MCPOAuth::revoke_all();
618 }
619
620 return self::public_status();
621 }
622
623 /**
624 * Map a read-only flag to the granted scope list.
625 *
626 * @since 4.9.0
627 *
628 * @param bool $read_only Whether to grant read-only access.
629 * @return string[]
630 */
631 private static function scopes_for( $read_only ) {
632 return $read_only ? [ 'read' ] : self::DEFAULT_SCOPES;
633 }
634
635 /**
636 * Mint a 32-byte random token (64 hex characters).
637 *
638 * @since 4.9.0
639 *
640 * @return string
641 */
642 private static function mint_token() {
643 return bin2hex( random_bytes( 32 ) );
644 }
645
646 /**
647 * SHA-256 verifier for the pairing token at rest.
648 *
649 * Mirrors `MCPOAuth`'s own hashing, which has always stored access and
650 * refresh tokens this way.
651 *
652 * @since 4.9.0
653 *
654 * @param string $value Raw token.
655 * @return string
656 */
657 private static function hash( $value ) {
658 return hash( 'sha256', (string) $value );
659 }
660
661 /**
662 * Re-store a legacy plaintext token encrypted, with its hash.
663 *
664 * @since 4.9.0
665 *
666 * @param string $token Raw token, already verified.
667 * @return void
668 */
669 private static function upgrade_legacy_storage( $token ) {
670 $stored = get_option( self::OPTION, [] );
671
672 if ( ! is_array( $stored ) ) {
673 return;
674 }
675
676 $stored['site_token'] = SecretAtRest::encrypt( $token );
677 $stored['token_hash'] = self::hash( $token );
678
679 update_option( self::OPTION, $stored, false );
680 }
681 }
682