| @@ -37,8 +37,9 @@ | ||
| 37 | 37 | |
| 38 | 38 | namespace XSpeed\Modules\Mcp; |
| 39 | 39 | |
| 40 | 40 | use XSpeed\Module; |
| 41 | +use XSpeed\Onboarding; | |
| 41 | 42 | |
| 42 | 43 | defined( 'ABSPATH' ) || exit; |
| 43 | 44 | |
| 44 | 45 | final class McpModule extends Module { |
| @@ -46,11 +47,40 @@ | ||
| 46 | 47 | public const SLUG = 'mcp'; |
| 47 | 48 | public const TIER = self::TIER_FREE; |
| 48 | 49 | public const VERSION = '1.0.0'; |
| 49 | 50 | |
| 50 | - /** REST namespace shared with Free. */ | |
| 51 | - private const NS = 'xspeed/v1'; | |
| 51 | + /** | |
| 52 | + * Every rewrite rule this module registers, in registration order. | |
| 53 | + * | |
| 54 | + * Single source of truth: add_rewrite() registers these, and the self-heal | |
| 55 | + * guard re-flushes when any is missing from the stored table. They were two | |
| 56 | + * hand-maintained lists before, which is a silent drift risk — a rule | |
| 57 | + * dropped from one and not the other leaves the guard restoring a rule | |
| 58 | + * nothing registers, or never firing for one that is registered. | |
| 59 | + * | |
| 60 | + * @var string[] Rewrite regexes. The query each maps to is built in | |
| 61 | + * add_rewrite(), which also fixes their order. | |
| 62 | + */ | |
| 63 | + public const REWRITE_RULES = array( | |
| 64 | + '^xspeed/mcp/([a-f0-9]{64})/?$', | |
| 65 | + '^xspeed/mcp/?$', | |
| 66 | + '^xspeed/mcp/attach/?$', | |
| 67 | + // OAuth discovery, root form. RFC 9728 §3.1 / RFC 8414 §3.1 put the | |
| 68 | + // `.well-known` segment BEFORE the resource path. | |
| 69 | + '^\.well-known/oauth-(protected-resource|authorization-server)/?$', | |
| 70 | + // OAuth discovery, path-suffixed form. Real clients (Claude Desktop | |
| 71 | + // among them) request THIS one; serving only the root form 404s them. | |
| 72 | + // It names our own resource path explicitly: a catch-all tail here | |
| 73 | + // also matched other MCP plugins' discovery URLs on the same site and | |
| 74 | + // answered them with our metadata, which broke their connectors. | |
| 75 | + '^\.well-known/oauth-(protected-resource|authorization-server)/xspeed/mcp/?$', | |
| 76 | + '^xspeed/authorize/?$', | |
| 77 | + ); | |
| 52 | 78 | |
| 79 | + /** REST namespace shared with Free. Public: Mcp_Server builds the | |
| 80 | + * discovery fallback URL from it. */ | |
| 81 | + public const NS = 'xspeed/v1'; | |
| 82 | + | |
| 53 | 83 | /** Query var flagging a pretty /xspeed/mcp request. */ |
| 54 | 84 | private const QUERY_VAR = 'xspeed_mcp'; |
| 55 | 85 | |
| 56 | 86 | /** Query var carrying the token when embedded in the URL path. */ |
| @@ -72,13 +102,16 @@ | ||
| 72 | 102 | |
| 73 | 103 | /** Front-end path of the browser-facing authorize page. */ |
| 74 | 104 | private const AUTHORIZE_PATH = 'xspeed/authorize'; |
| 75 | 105 | |
| 106 | + /** Query var flagging the pretty /xspeed/mcp/attach callback. */ | |
| 107 | + private const ATTACH_QUERY_VAR = 'xspeed_mcp_attach'; | |
| 108 | + | |
| 76 | 109 | public function ui_metadata(): array { |
| 77 | 110 | return array( |
| 78 | - 'label' => 'MCP Server', | |
| 111 | + 'label' => __( 'MCP Server', 'xspeed' ), | |
| 79 | 112 | 'icon' => 'Sparkles', |
| 80 | - 'description' => 'Control this site\'s cache from Claude and other AI agents.', | |
| 113 | + 'description' => __( 'Control this site\'s cache from Claude and other AI agents.', 'xspeed' ), | |
| 81 | 114 | 'custom_panel' => 'McpPanel', |
| 82 | 115 | ); |
| 83 | 116 | } |
| 84 | 117 | |
| @@ -104,12 +137,95 @@ | ||
| 104 | 137 | |
| 105 | 138 | // Pretty per-site endpoint: /xspeed/mcp → MCP JSON-RPC handler. |
| 106 | 139 | add_action( 'init', array( $this, 'add_rewrite' ) ); |
| 107 | 140 | add_filter( 'query_vars', array( $this, 'register_query_var' ) ); |
| 108 | - add_action( 'parse_request', array( $this, 'maybe_handle_pretty_endpoint' ) ); | |
| 141 | + // Priority 1: a sibling MCP plugin that also claims /.well-known/ gets | |
| 142 | + // to answer first at the default priority 10, and whoever answers | |
| 143 | + // first calls exit(). Running early means the URL is decided by WHOSE | |
| 144 | + // path it is, not by which plugin happened to load last. | |
| 145 | + add_action( 'parse_request', array( $this, 'maybe_handle_pretty_endpoint' ), 1 ); | |
| 146 | + | |
| 147 | + // Hub redirect-return: after the user approves on the Hub, it sends the | |
| 148 | + // browser back to a plugin admin URL carrying ?xspeed_connected=1 plus | |
| 149 | + // the account email + the SAME signed nonce we minted. We verify our own | |
| 150 | + // nonce and mark this admin attached — no server-to-server callback | |
| 151 | + // needed, so it works for local/firewalled sites too. | |
| 152 | + add_action( 'admin_init', array( $this, 'maybe_handle_hub_return' ) ); | |
| 153 | + | |
| 154 | + // An attached admin who is DELETED (or removed from the blog) never | |
| 155 | + // runs disconnect(), so the site-level attached mirror would report | |
| 156 | + // hub:true forever. deleted_user fires after both wp_delete_user() | |
| 157 | + // and wpmu_delete_user() drop the user, so a plain recompute is | |
| 158 | + // honest there. remove_user_from_blog is core's only removal action | |
| 159 | + // and fires BEFORE removal, so its handler clears the departing | |
| 160 | + // user's record before recomputing (see Mcp_Hub::handle_user_removed). | |
| 161 | + add_action( 'deleted_user', array( Mcp_Hub::class, 'refresh_site_attached' ) ); | |
| 162 | + add_action( 'remove_user_from_blog', array( Mcp_Hub::class, 'handle_user_removed' ) ); | |
| 109 | 163 | } |
| 110 | 164 | |
| 111 | 165 | /** |
| 166 | + * Handle the browser landing back from the Hub after a connect. Idempotent | |
| 167 | + * and safe to run on every admin page load: it only acts when the return | |
| 168 | + * markers are present and the nonce verifies. | |
| 169 | + */ | |
| 170 | + public function maybe_handle_hub_return(): void { | |
| 171 | + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- auth is the signed HMAC nonce below, not a WP nonce; this is a read-only routing check. | |
| 172 | + $nonce = isset( $_GET['xspeed_hub_nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['xspeed_hub_nonce'] ) ) : ''; | |
| 173 | + $email = isset( $_GET['xspeed_hub_email'] ) ? sanitize_email( wp_unslash( $_GET['xspeed_hub_email'] ) ) : ''; | |
| 174 | + | |
| 175 | + /* | |
| 176 | + * Trigger on the signed nonce, not on `xspeed_connected`. | |
| 177 | + * | |
| 178 | + * The Hub bounces the browser back with xspeed_hub_nonce + | |
| 179 | + * xspeed_hub_email, but it does NOT always append xspeed_connected — | |
| 180 | + * that marker only survives when the return_url we handed it carried | |
| 181 | + * one. Gating on it meant a real, correctly-signed return was ignored: | |
| 182 | + * the attach was never recorded, the params were never stripped, and | |
| 183 | + * the card kept showing "Not connected" while the nonce sat in the | |
| 184 | + * address bar. The nonce is the actual proof of a genuine round trip, | |
| 185 | + * so it is what this handler keys on. (FBS-84086) | |
| 186 | + */ | |
| 187 | + if ( '' === $nonce && empty( $_GET['xspeed_connected'] ) ) { | |
| 188 | + return; | |
| 189 | + } | |
| 190 | + // phpcs:enable WordPress.Security.NonceVerification.Recommended | |
| 191 | + | |
| 192 | + if ( ! current_user_can( 'manage_options' ) ) { | |
| 193 | + return; | |
| 194 | + } | |
| 195 | + | |
| 196 | + // Verify OUR own signed nonce (proves the round-trip went through the | |
| 197 | + // Hub with a token we minted), then record the connection. | |
| 198 | + if ( '' !== $nonce ) { | |
| 199 | + $verified = Mcp_Hub::verify_attach_nonce( $nonce ); | |
| 200 | + if ( null !== $verified ) { | |
| 201 | + $uid = isset( $verified['user_id'] ) ? (int) $verified['user_id'] : get_current_user_id(); | |
| 202 | + Mcp_Hub::mark_attached( $email, $uid ?: null ); | |
| 203 | + } | |
| 204 | + } | |
| 205 | + | |
| 206 | + // ALWAYS strip the one-time return markers from the URL and redirect to | |
| 207 | + // the clean address. These params are single-use; if they persist in the | |
| 208 | + // browser URL, a later reload re-triggers the "just connected" path and | |
| 209 | + // flashes a stale connected state even after the user has disconnected. | |
| 210 | + $clean = remove_query_arg( array( 'xspeed_connected', 'xspeed_hub_nonce', 'xspeed_hub_email' ) ); | |
| 211 | + | |
| 212 | + // The setup wizard keeps its current step in component state, so a | |
| 213 | + // redirect remounts it at step 1 — dumping the user back at the START of | |
| 214 | + // onboarding immediately after they finished its LAST step. Carry a | |
| 215 | + // durable hint so the wizard resumes on Connect instead. It's a plain | |
| 216 | + // step marker, not an auth signal (the nonce above did that job), and | |
| 217 | + // it's safe to leave in the URL: re-loading it just re-opens the same | |
| 218 | + // step rather than re-running the connect path. (PM feedback) | |
| 219 | + if ( false !== strpos( (string) $clean, 'page=' . Onboarding::PAGE_SLUG ) ) { | |
| 220 | + $clean = add_query_arg( 'xspeed_step', 'connect', $clean ); | |
| 221 | + } | |
| 222 | + | |
| 223 | + wp_safe_redirect( $clean ); | |
| 224 | + exit; | |
| 225 | + } | |
| 226 | + | |
| 227 | + /** | |
| 112 | 228 | * Flush rewrites once when the module first boots so /xspeed/mcp works |
| 113 | 229 | * without a manual permalink re-save. Cheap: gated on a one-shot flag. |
| 114 | 230 | */ |
| 115 | 231 | public function activate(): void { |
| @@ -133,22 +249,40 @@ | ||
| 133 | 249 | 'top' |
| 134 | 250 | ); |
| 135 | 251 | add_rewrite_rule( '^xspeed/mcp/?$', 'index.php?' . self::QUERY_VAR . '=1', 'top' ); |
| 136 | 252 | |
| 253 | + // Pretty attach-callback endpoint: /xspeed/mcp/attach — the hub POSTs | |
| 254 | + // the signed nonce here to verify + fetch the token. Uses the plugin's | |
| 255 | + // own rewrite (consistent with the MCP URL, survives hosts that block | |
| 256 | + // /wp-json). Placed BEFORE the token rule would never match "attach" | |
| 257 | + // (that rule requires 64 hex chars), so ordering is safe. | |
| 258 | + add_rewrite_rule( '^xspeed/mcp/attach/?$', 'index.php?' . self::ATTACH_QUERY_VAR . '=1', 'top' ); | |
| 259 | + | |
| 137 | 260 | // OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place the |
| 138 | - // `.well-known` segment BEFORE the resource path, so a resource at | |
| 261 | + // `.well-known` segment BEFORE the resource path, so our resource at | |
| 139 | 262 | // /xspeed/mcp is discovered at BOTH: |
| 140 | 263 | // /.well-known/oauth-protected-resource (root form) |
| 141 | 264 | // /.well-known/oauth-protected-resource/xspeed/mcp (path-suffixed) |
| 142 | - // Real clients (e.g. Claude Desktop) request the path-suffixed form; | |
| 143 | - // serving only the root form 404s them and the connection aborts. The | |
| 144 | - // optional `(?:/.*)?` tail matches both without caring about the exact | |
| 145 | - // resource path (we only serve one resource). | |
| 265 | + // Real clients (Claude Desktop among them) request the path-suffixed | |
| 266 | + // form; serving only the root form 404s them and the connection aborts. | |
| 267 | + // | |
| 268 | + // Both are matched EXACTLY. A `(?:/.*)?` tail covers the same two URLs | |
| 269 | + // in one rule, but also matches every OTHER plugin's discovery URL on | |
| 270 | + // the same site — and WordPress matches rewrite rules in table order | |
| 271 | + // rather than by specificity, so a sibling's own exact rule never gets | |
| 272 | + // reached. Its clients then receive OUR metadata, find a resource and | |
| 273 | + // issuer that do not match what they are connecting to, and abort | |
| 274 | + // before the login screen. | |
| 146 | 275 | add_rewrite_rule( |
| 147 | - '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$', | |
| 276 | + '^\\.well-known/oauth-(protected-resource|authorization-server)/?$', | |
| 148 | 277 | 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', |
| 149 | 278 | 'top' |
| 150 | 279 | ); |
| 280 | + add_rewrite_rule( | |
| 281 | + '^\\.well-known/oauth-(protected-resource|authorization-server)/xspeed/mcp/?$', | |
| 282 | + 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', | |
| 283 | + 'top' | |
| 284 | + ); | |
| 151 | 285 | |
| 152 | 286 | // Browser-facing OAuth consent page — served OUTSIDE REST so cookie |
| 153 | 287 | // auth (is_user_logged_in) works after the wp-login round-trip. |
| 154 | 288 | add_rewrite_rule( '^xspeed/authorize/?$', 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', 'top' ); |
| @@ -158,17 +292,11 @@ | ||
| 158 | 292 | // flushed under an older build (which had /xspeed/mcp but not the |
| 159 | 293 | // later /xspeed/authorize + /.well-known rules) keeps that first rule, |
| 160 | 294 | // so the guard never fires and OAuth discovery 404s forever. Guard on |
| 161 | 295 | // the full set so any newly-added rule triggers a re-flush. |
| 162 | - $expected = array( | |
| 163 | - '^xspeed/mcp/([a-f0-9]{64})/?$', | |
| 164 | - '^xspeed/mcp/?$', | |
| 165 | - '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$', | |
| 166 | - '^xspeed/authorize/?$', | |
| 167 | - ); | |
| 168 | 296 | $rules = get_option( 'rewrite_rules' ); |
| 169 | 297 | if ( is_array( $rules ) ) { |
| 170 | - foreach ( $expected as $rule ) { | |
| 298 | + foreach ( self::REWRITE_RULES as $rule ) { | |
| 171 | 299 | if ( ! isset( $rules[ $rule ] ) ) { |
| 172 | 300 | flush_rewrite_rules( false ); |
| 173 | 301 | break; |
| 174 | 302 | } |
| @@ -176,8 +304,41 @@ | ||
| 176 | 304 | } |
| 177 | 305 | } |
| 178 | 306 | |
| 179 | 307 | /** |
| 308 | + * True when a request for our discovery URL can reach WordPress at all. | |
| 309 | + * | |
| 310 | + * Since maybe_handle_pretty_endpoint() claims the document by REQUEST | |
| 311 | + * PATH, a sibling plugin winning the rewrite match no longer matters — | |
| 312 | + * we answer either way. What still breaks the pretty URL is there being | |
| 313 | + * no rewrite for it in the first place (plain permalinks), because then | |
| 314 | + * nothing routes the path to index.php and parse_request never runs. | |
| 315 | + * | |
| 316 | + * Blind to upstream interception: a host that owns the /.well-known/ | |
| 317 | + * prefix (an nginx ACME block, an edge redirect rule) answers before | |
| 318 | + * WordPress loads, and WP cannot see that. Use the | |
| 319 | + * `xspeed_mcp_resource_metadata_url` filter on such hosts. | |
| 320 | + */ | |
| 321 | + public static function wellknown_rewrites_active(): bool { | |
| 322 | + $rules = get_option( 'rewrite_rules' ); | |
| 323 | + if ( ! is_array( $rules ) || array() === $rules ) { | |
| 324 | + return false; | |
| 325 | + } | |
| 326 | + | |
| 327 | + // Any rule that routes our discovery path to index.php will do — ours | |
| 328 | + // or a sibling's — because the path check inside the handler decides | |
| 329 | + // the outcome once the request lands. | |
| 330 | + $probe = '.well-known/oauth-protected-resource'; | |
| 331 | + foreach ( $rules as $pattern => $target ) { | |
| 332 | + if ( preg_match( '#' . str_replace( '#', '\\#', $pattern ) . '#', $probe ) ) { | |
| 333 | + return true; | |
| 334 | + } | |
| 335 | + } | |
| 336 | + | |
| 337 | + return false; | |
| 338 | + } | |
| 339 | + | |
| 340 | + /** | |
| 180 | 341 | * @param string[] $vars Registered query vars. |
| 181 | 342 | * @return string[] |
| 182 | 343 | */ |
| 183 | 344 | public function register_query_var( array $vars ): array { |
| @@ -184,12 +345,50 @@ | ||
| 184 | 345 | $vars[] = self::QUERY_VAR; |
| 185 | 346 | $vars[] = self::TOKEN_QUERY_VAR; |
| 186 | 347 | $vars[] = self::WELLKNOWN_QUERY_VAR; |
| 187 | 348 | $vars[] = self::AUTHORIZE_QUERY_VAR; |
| 349 | + $vars[] = self::ATTACH_QUERY_VAR; | |
| 188 | 350 | return $vars; |
| 189 | 351 | } |
| 190 | 352 | |
| 191 | 353 | /** |
| 354 | + * Which discovery document the CURRENT request path asks for, if any. | |
| 355 | + * | |
| 356 | + * Claims only URLs that are unambiguously ours, mirroring the rewrite | |
| 357 | + * rules exactly: the bare root form, and the RFC 9728 §3.1 path-suffixed | |
| 358 | + * form naming our own resource (`/xspeed/mcp`). A suffix belonging to a | |
| 359 | + * sibling plugin is deliberately NOT claimed — answering | |
| 360 | + * `/.well-known/oauth-protected-resource/betterlinks/mcp` with xSpeed | |
| 361 | + * metadata is the same bug that broke this site, just pointed the other | |
| 362 | + * way. | |
| 363 | + * | |
| 364 | + * @return string 'protected-resource', 'authorization-server', or ''. | |
| 365 | + */ | |
| 366 | + private function wellknown_doc_from_path(): string { | |
| 367 | + $uri = isset( $_SERVER['REQUEST_URI'] ) | |
| 368 | + ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) | |
| 369 | + : ''; | |
| 370 | + if ( '' === $uri ) { | |
| 371 | + return ''; | |
| 372 | + } | |
| 373 | + | |
| 374 | + $path = (string) wp_parse_url( $uri, PHP_URL_PATH ); | |
| 375 | + | |
| 376 | + // Sites in a subdirectory carry that prefix on every request. | |
| 377 | + $home = (string) wp_parse_url( home_url(), PHP_URL_PATH ); | |
| 378 | + if ( '' !== $home && '/' !== $home && 0 === strpos( $path, $home ) ) { | |
| 379 | + $path = substr( $path, strlen( $home ) ); | |
| 380 | + } | |
| 381 | + | |
| 382 | + $path = trim( $path, '/' ); | |
| 383 | + | |
| 384 | + $pattern = '#^\.well-known/oauth-(protected-resource|authorization-server)' | |
| 385 | + . '(?:/xspeed/mcp)?$#'; | |
| 386 | + | |
| 387 | + return preg_match( $pattern, $path, $m ) ? $m[1] : ''; | |
| 388 | + } | |
| 389 | + | |
| 390 | + /** | |
| 192 | 391 | * Serve the MCP endpoint on the pretty path. Runs on parse_request so |
| 193 | 392 | * it fires before the main query, and short-circuits WP entirely. |
| 194 | 393 | * |
| 195 | 394 | * @param \WP $wp The WP request object. |
| @@ -195,10 +394,24 @@ | ||
| 195 | 394 | * @param \WP $wp The WP request object. |
| 196 | 395 | */ |
| 197 | 396 | public function maybe_handle_pretty_endpoint( $wp ): void { |
| 198 | 397 | // OAuth discovery documents (served at the site root). |
| 199 | - if ( ! empty( $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] ) ) { | |
| 398 | + // | |
| 399 | + // Read the doc name from the REQUEST PATH, not just our query var. | |
| 400 | + // `add_rewrite_rule( …, 'top' )` only means "top at the moment it | |
| 401 | + // runs", so whichever MCP plugin hooks `init` last ends up first in | |
| 402 | + // the table — an order set by plugin load order, which no plugin | |
| 403 | + // controls. A sibling's catch-all | |
| 404 | + // (`…(protected-resource|authorization-server)(?:/.*)?/?$`) then wins | |
| 405 | + // the match and our query var is never set, even though the URL is | |
| 406 | + // unambiguously ours. Observed live with two different plugins on one | |
| 407 | + // site. parse_request runs AFTER matching, so the path is the one | |
| 408 | + // signal no sibling rule can take away from us. | |
| 409 | + $doc = $this->wellknown_doc_from_path(); | |
| 410 | + if ( '' === $doc && ! empty( $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] ) ) { | |
| 200 | 411 | $doc = (string) $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ]; |
| 412 | + } | |
| 413 | + if ( '' !== $doc ) { | |
| 201 | 414 | $data = 'authorization-server' === $doc |
| 202 | 415 | ? Mcp_OAuth::authorization_server_metadata() |
| 203 | 416 | : Mcp_OAuth::protected_resource_metadata(); |
| 204 | 417 | status_header( 200 ); |
| @@ -208,8 +421,27 @@ | ||
| 208 | 421 | echo wp_json_encode( $data ); |
| 209 | 422 | exit; |
| 210 | 423 | } |
| 211 | 424 | |
| 425 | + // Pretty attach-callback: /xspeed/mcp/attach. The hub POSTs the signed | |
| 426 | + // nonce; we verify it and return this site's URL + token. Auth is the | |
| 427 | + // nonce itself (admin-minted, HMAC-signed), so no credential needed. | |
| 428 | + if ( ! empty( $wp->query_vars[ self::ATTACH_QUERY_VAR ] ) ) { | |
| 429 | + $body = json_decode( (string) file_get_contents( 'php://input' ), true ); | |
| 430 | + $nonce = is_array( $body ) && isset( $body['nonce'] ) ? (string) $body['nonce'] : ''; | |
| 431 | + $result = Mcp_Hub::verify_attach_nonce( $nonce ); | |
| 432 | + header( 'Content-Type: application/json; charset=utf-8' ); | |
| 433 | + header( 'Cache-Control: no-store' ); | |
| 434 | + if ( null === $result ) { | |
| 435 | + status_header( 403 ); | |
| 436 | + echo wp_json_encode( array( 'error' => 'invalid_or_expired_attach_request' ) ); | |
| 437 | + } else { | |
| 438 | + status_header( 200 ); | |
| 439 | + echo wp_json_encode( $result ); | |
| 440 | + } | |
| 441 | + exit; | |
| 442 | + } | |
| 443 | + | |
| 212 | 444 | // Browser-facing OAuth consent page (cookie auth applies here). |
| 213 | 445 | if ( ! empty( $wp->query_vars[ self::AUTHORIZE_QUERY_VAR ] ) ) { |
| 214 | 446 | $this->handle_authorize_page(); |
| 215 | 447 | return; |
| @@ -259,8 +491,26 @@ | ||
| 259 | 491 | 'permission_callback' => '__return_true', |
| 260 | 492 | ) |
| 261 | 493 | ); |
| 262 | 494 | |
| 495 | + // --- Public scan signals ----------------------------------------- | |
| 496 | + // One tiny unauthenticated JSON body for external audit tools (the | |
| 497 | + // speed scanner on xspeedcache.com): plugin version, whether the MCP | |
| 498 | + // server is connected, and whether the site is attached to xSpeed | |
| 499 | + // Hub. Everything except `hub` is already publicly discoverable — | |
| 500 | + // the cache signature carries the version and /mcp answers 401 when | |
| 501 | + // connected — and `hub` is a bare boolean. No tokens, accounts or | |
| 502 | + // emails leave through this route. | |
| 503 | + register_rest_route( | |
| 504 | + self::NS, | |
| 505 | + '/signals', | |
| 506 | + array( | |
| 507 | + 'methods' => 'GET', | |
| 508 | + 'callback' => array( $this, 'rest_signals' ), | |
| 509 | + 'permission_callback' => '__return_true', | |
| 510 | + ) | |
| 511 | + ); | |
| 512 | + | |
| 263 | 513 | // --- Admin-only management routes (dashboard) -------------------- |
| 264 | 514 | register_rest_route( |
| 265 | 515 | self::NS, |
| 266 | 516 | '/mcp/connection', |
| @@ -271,8 +521,34 @@ | ||
| 271 | 521 | ) |
| 272 | 522 | ); |
| 273 | 523 | register_rest_route( |
| 274 | 524 | self::NS, |
| 525 | + '/mcp/activity', | |
| 526 | + array( | |
| 527 | + 'methods' => 'GET', | |
| 528 | + 'callback' => array( $this, 'rest_activity' ), | |
| 529 | + 'permission_callback' => array( $this, 'admin_permission' ), | |
| 530 | + 'args' => array( | |
| 531 | + 'limit' => array( | |
| 532 | + 'type' => 'integer', | |
| 533 | + 'required' => false, | |
| 534 | + 'default' => 50, | |
| 535 | + 'description' => 'Maximum entries to return (newest first).', | |
| 536 | + ), | |
| 537 | + ), | |
| 538 | + ) | |
| 539 | + ); | |
| 540 | + register_rest_route( | |
| 541 | + self::NS, | |
| 542 | + '/mcp/activity/clear', | |
| 543 | + array( | |
| 544 | + 'methods' => 'POST', | |
| 545 | + 'callback' => array( $this, 'rest_activity_clear' ), | |
| 546 | + 'permission_callback' => array( $this, 'admin_permission' ), | |
| 547 | + ) | |
| 548 | + ); | |
| 549 | + register_rest_route( | |
| 550 | + self::NS, | |
| 275 | 551 | '/mcp/connect', |
| 276 | 552 | array( |
| 277 | 553 | 'methods' => 'POST', |
| 278 | 554 | 'callback' => array( $this, 'rest_connect' ), |
| @@ -328,8 +604,72 @@ | ||
| 328 | 604 | 'permission_callback' => array( $this, 'admin_permission' ), |
| 329 | 605 | ) |
| 330 | 606 | ); |
| 331 | 607 | |
| 608 | + // --- xSpeed Hub (multi-site) attach routes ------------------------ | |
| 609 | + register_rest_route( | |
| 610 | + self::NS, | |
| 611 | + '/mcp/hub', | |
| 612 | + array( | |
| 613 | + 'methods' => 'GET', | |
| 614 | + 'callback' => array( $this, 'rest_hub_status' ), | |
| 615 | + 'permission_callback' => array( $this, 'admin_permission' ), | |
| 616 | + ) | |
| 617 | + ); | |
| 618 | + register_rest_route( | |
| 619 | + self::NS, | |
| 620 | + '/mcp/hub/token', | |
| 621 | + array( | |
| 622 | + 'methods' => 'POST', | |
| 623 | + 'callback' => array( $this, 'rest_hub_token' ), | |
| 624 | + 'permission_callback' => array( $this, 'admin_permission' ), | |
| 625 | + ) | |
| 626 | + ); | |
| 627 | + register_rest_route( | |
| 628 | + self::NS, | |
| 629 | + '/mcp/hub/attached', | |
| 630 | + array( | |
| 631 | + 'methods' => 'POST', | |
| 632 | + 'callback' => array( $this, 'rest_hub_attached' ), | |
| 633 | + 'permission_callback' => array( $this, 'admin_permission' ), | |
| 634 | + 'args' => array( | |
| 635 | + 'account_email' => array( | |
| 636 | + 'type' => 'string', | |
| 637 | + 'required' => true, | |
| 638 | + 'description' => 'The hub account email this site was attached to.', | |
| 639 | + ), | |
| 640 | + ), | |
| 641 | + ) | |
| 642 | + ); | |
| 643 | + register_rest_route( | |
| 644 | + self::NS, | |
| 645 | + '/mcp/hub/disconnect', | |
| 646 | + array( | |
| 647 | + 'methods' => 'POST', | |
| 648 | + 'callback' => array( $this, 'rest_hub_disconnect' ), | |
| 649 | + 'permission_callback' => array( $this, 'admin_permission' ), | |
| 650 | + ) | |
| 651 | + ); | |
| 652 | + // OAuth-attach callback: the hub calls this with the signed nonce the | |
| 653 | + // plugin issued. Auth is the nonce itself (no pre-shared token), so | |
| 654 | + // permission_callback is open — the handler validates the nonce. | |
| 655 | + register_rest_route( | |
| 656 | + self::NS, | |
| 657 | + '/mcp/attach', | |
| 658 | + array( | |
| 659 | + 'methods' => 'POST', | |
| 660 | + 'callback' => array( $this, 'rest_hub_attach_callback' ), | |
| 661 | + 'permission_callback' => '__return_true', | |
| 662 | + 'args' => array( | |
| 663 | + 'nonce' => array( | |
| 664 | + 'type' => 'string', | |
| 665 | + 'required' => true, | |
| 666 | + 'description' => 'The signed attach nonce the plugin issued.', | |
| 667 | + ), | |
| 668 | + ), | |
| 669 | + ) | |
| 670 | + ); | |
| 671 | + | |
| 332 | 672 | // --- OAuth 2.1 authorization server (the "paste a URL only" path) - |
| 333 | 673 | // Discovery, dynamic client registration, and the token endpoint are |
| 334 | 674 | // all public (permission enforced inside): a client must reach them |
| 335 | 675 | // BEFORE it holds any credential. The authorize endpoint gates on a |
| @@ -355,13 +695,49 @@ | ||
| 355 | 695 | 'permission_callback' => '__return_true', |
| 356 | 696 | ) |
| 357 | 697 | ); |
| 358 | 698 | |
| 699 | + // --- OAuth discovery, REST fallback ------------------------------ | |
| 700 | + // The canonical documents live at /.well-known/… via rewrite rules. | |
| 701 | + // Many hosts own that prefix for ACME/Let's Encrypt (an nginx | |
| 702 | + // `location ^~ /.well-known` block, or an edge redirect rule), which | |
| 703 | + // swallows the request before WordPress ever runs — the pretty URL | |
| 704 | + // then 404s or redirects to the homepage no matter how the plugin is | |
| 705 | + // configured, and OAuth discovery dead-ends with no way back. | |
| 706 | + // Serving the same two documents under /wp-json puts them on a path | |
| 707 | + // no ACME tooling claims, so discovery still completes there. | |
| 708 | + register_rest_route( | |
| 709 | + self::NS, | |
| 710 | + '/mcp/.well-known/oauth-protected-resource', | |
| 711 | + array( | |
| 712 | + 'methods' => 'GET', | |
| 713 | + 'callback' => array( $this, 'rest_protected_resource_metadata' ), | |
| 714 | + 'permission_callback' => '__return_true', | |
| 715 | + ) | |
| 716 | + ); | |
| 717 | + register_rest_route( | |
| 718 | + self::NS, | |
| 719 | + '/mcp/.well-known/oauth-authorization-server', | |
| 720 | + array( | |
| 721 | + 'methods' => 'GET', | |
| 722 | + 'callback' => array( $this, 'rest_authorization_server_metadata' ), | |
| 723 | + 'permission_callback' => '__return_true', | |
| 724 | + ) | |
| 725 | + ); | |
| 726 | + | |
| 359 | 727 | // --- MCP-token-only tool routes (optional hosted-broker path) ---- |
| 360 | 728 | $tool_perm = array( Mcp_Auth::class, 'permission' ); |
| 361 | 729 | register_rest_route( |
| 362 | 730 | self::NS, |
| 363 | - '/mcp/tool/(?P<tool>[a-z_]+)', | |
| 731 | + // [a-z0-9_-]+ — the HYPHEN is the one that matters, not the digit. | |
| 732 | + // Generated tool names carry their module slug verbatim, and 33 of | |
| 733 | + // the 92 in the catalog have a hyphenated slug | |
| 734 | + // (xspeed_cache-404_status, xspeed_migration-pro_apply, | |
| 735 | + // xspeed_smart-predict_status …). Every one of those returned | |
| 736 | + // rest_no_route through the broker path. The earlier widening to | |
| 737 | + // [a-z0-9_]+ un-blocked nothing: the only digit-bearing name is | |
| 738 | + // cache-404, whose problem was the hyphen. (QA on #158) */ | |
| 739 | + '/mcp/tool/(?P<tool>[a-z0-9_-]+)', | |
| 364 | 740 | array( |
| 365 | 741 | array( |
| 366 | 742 | 'methods' => 'GET', |
| 367 | 743 | 'callback' => array( $this, 'rest_tool' ), |
| @@ -401,8 +777,35 @@ | ||
| 401 | 777 | return $response; |
| 402 | 778 | } |
| 403 | 779 | |
| 404 | 780 | /** |
| 781 | + * GET /signals — the public scan-signals body. See the route | |
| 782 | + * registration for what may (and may not) leave through it. | |
| 783 | + * | |
| 784 | + * @return \WP_REST_Response | |
| 785 | + */ | |
| 786 | + public function rest_signals() { | |
| 787 | + $signals = array( | |
| 788 | + 'xspeed' => XSPEED_VERSION, | |
| 789 | + 'mcp' => '' !== Mcp_Pairing::site_token(), | |
| 790 | + 'hub' => Mcp_Hub::site_attached(), | |
| 791 | + ); | |
| 792 | + | |
| 793 | + /** | |
| 794 | + * Filter the public scan signals. | |
| 795 | + * | |
| 796 | + * Lets an add-on append its own public facts (e.g. its version | |
| 797 | + * under `pro`). Values returned here are served UNAUTHENTICATED — | |
| 798 | + * never add tokens, accounts, emails, or paths. | |
| 799 | + * | |
| 800 | + * @param array<string,mixed> $signals The signals body. | |
| 801 | + */ | |
| 802 | + $signals = (array) apply_filters( 'xspeed_scan_signals', $signals ); | |
| 803 | + | |
| 804 | + return rest_ensure_response( $signals ); | |
| 805 | + } | |
| 806 | + | |
| 807 | + /** | |
| 405 | 808 | * GET /mcp/connection — pairing status for the dashboard. |
| 406 | 809 | * |
| 407 | 810 | * @param \WP_REST_Request $request Unused. |
| 408 | 811 | * @return \WP_REST_Response |
| @@ -412,8 +815,44 @@ | ||
| 412 | 815 | return rest_ensure_response( Mcp_Pairing::public_status() ); |
| 413 | 816 | } |
| 414 | 817 | |
| 415 | 818 | /** |
| 819 | + * GET /mcp/activity — the audit trail of AI tool calls. | |
| 820 | + * | |
| 821 | + * @param \WP_REST_Request $request Carries the optional limit. | |
| 822 | + * @return \WP_REST_Response|\WP_Error | |
| 823 | + */ | |
| 824 | + public function rest_activity( \WP_REST_Request $request ) { | |
| 825 | + $limit = (int) $request->get_param( 'limit' ); | |
| 826 | + | |
| 827 | + return rest_ensure_response( | |
| 828 | + array( | |
| 829 | + 'entries' => Mcp_Activity_Log::entries( $limit > 0 ? $limit : 50 ), | |
| 830 | + 'summary' => Mcp_Activity_Log::summary(), | |
| 831 | + ) | |
| 832 | + ); | |
| 833 | + } | |
| 834 | + | |
| 835 | + /** | |
| 836 | + * POST /mcp/activity/clear — wipe the audit trail. | |
| 837 | + * | |
| 838 | + * @param \WP_REST_Request $request Unused. | |
| 839 | + * @return \WP_REST_Response|\WP_Error | |
| 840 | + */ | |
| 841 | + public function rest_activity_clear( \WP_REST_Request $request ) { | |
| 842 | + unset( $request ); | |
| 843 | + $cleared = Mcp_Activity_Log::clear(); | |
| 844 | + | |
| 845 | + return rest_ensure_response( | |
| 846 | + array( | |
| 847 | + 'cleared' => $cleared, | |
| 848 | + 'entries' => Mcp_Activity_Log::entries(), | |
| 849 | + 'summary' => Mcp_Activity_Log::summary(), | |
| 850 | + ) | |
| 851 | + ); | |
| 852 | + } | |
| 853 | + | |
| 854 | + /** | |
| 416 | 855 | * POST /mcp/connect — mint a connection token. |
| 417 | 856 | * |
| 418 | 857 | * @param \WP_REST_Request $request Unused. |
| 419 | 858 | * @return \WP_REST_Response|\WP_Error |
| @@ -468,11 +907,130 @@ | ||
| 468 | 907 | unset( $request ); |
| 469 | 908 | return rest_ensure_response( Mcp_Pairing::disconnect() ); |
| 470 | 909 | } |
| 471 | 910 | |
| 911 | + // -- xSpeed Hub (multi-site) handlers ---------------------------------- | |
| 912 | + | |
| 913 | + /** | |
| 914 | + * GET /mcp/hub — hub-link status + the Method-1 paste-in values. | |
| 915 | + * | |
| 916 | + * @param \WP_REST_Request $request Unused. | |
| 917 | + * @return \WP_REST_Response | |
| 918 | + */ | |
| 919 | + public function rest_hub_status( \WP_REST_Request $request ) { | |
| 920 | + // Self-heal from the Hub (source of truth) so the connected badge is | |
| 921 | + // reliable even if the attach callback never fired. Force a fresh check | |
| 922 | + // when the panel asks via the X-XSpeed-Reconcile header (e.g. the admin | |
| 923 | + // returned to the tab after connecting). | |
| 924 | + $force = '1' === (string) $request->get_header( 'x_xspeed_reconcile' ); | |
| 925 | + Mcp_Hub::reconcile_with_hub( $force ); | |
| 926 | + return rest_ensure_response( Mcp_Hub::public_status() ); | |
| 927 | + } | |
| 928 | + | |
| 929 | + /** | |
| 930 | + * POST /mcp/hub/token — ensure a site_token exists and return the | |
| 931 | + * paste-in values (this site's URL + token) for the hub's Add-site form. | |
| 932 | + * | |
| 933 | + * @param \WP_REST_Request $request Unused. | |
| 934 | + * @return \WP_REST_Response | |
| 935 | + */ | |
| 936 | + public function rest_hub_token( \WP_REST_Request $request ) { | |
| 937 | + unset( $request ); | |
| 938 | + return rest_ensure_response( Mcp_Hub::generate_token() ); | |
| 939 | + } | |
| 940 | + | |
| 941 | + /** | |
| 942 | + * POST /mcp/hub/attached — record which hub account this site is | |
| 943 | + * attached to (bookkeeping for the panel's status line). | |
| 944 | + * | |
| 945 | + * @param \WP_REST_Request $request Carries account_email. | |
| 946 | + * @return \WP_REST_Response | |
| 947 | + */ | |
| 948 | + public function rest_hub_attached( \WP_REST_Request $request ) { | |
| 949 | + $email = sanitize_email( (string) $request->get_param( 'account_email' ) ); | |
| 950 | + return rest_ensure_response( Mcp_Hub::mark_attached( $email ) ); | |
| 951 | + } | |
| 952 | + | |
| 953 | + /** | |
| 954 | + * POST /mcp/hub/disconnect — clear the local hub-link bookkeeping. | |
| 955 | + * | |
| 956 | + * @param \WP_REST_Request $request Unused. | |
| 957 | + * @return \WP_REST_Response | |
| 958 | + */ | |
| 959 | + public function rest_hub_disconnect( \WP_REST_Request $request ) { | |
| 960 | + unset( $request ); | |
| 961 | + return rest_ensure_response( Mcp_Hub::disconnect() ); | |
| 962 | + } | |
| 963 | + | |
| 964 | + /** | |
| 965 | + * POST /mcp/attach — the OAuth-attach callback. The hub presents the | |
| 966 | + * signed nonce the plugin issued; on success we return this site's URL + | |
| 967 | + * token so the hub can record it. Nonce is the auth (admin-minted, | |
| 968 | + * HMAC-signed, time-bound), so no pre-shared token is required. | |
| 969 | + * | |
| 970 | + * @param \WP_REST_Request $request Carries the nonce. | |
| 971 | + * @return \WP_REST_Response|\WP_Error | |
| 972 | + */ | |
| 973 | + public function rest_hub_attach_callback( \WP_REST_Request $request ) { | |
| 974 | + $nonce = (string) $request->get_param( 'nonce' ); | |
| 975 | + $result = Mcp_Hub::verify_attach_nonce( $nonce ); | |
| 976 | + if ( null === $result ) { | |
| 977 | + return new \WP_Error( | |
| 978 | + 'xspeed_attach_invalid', | |
| 979 | + __( 'Invalid or expired attach request.', 'xspeed' ), | |
| 980 | + array( 'status' => 403 ) | |
| 981 | + ); | |
| 982 | + } | |
| 983 | + // A valid nonce proves this is a real hub-initiated attach, so record it | |
| 984 | + // now — the hub passes the account email so the panel can show | |
| 985 | + // "Connected via <email>". The nonce carries the minting admin's user | |
| 986 | + // id (no WP session exists in this server-to-server call), so the state | |
| 987 | + // is recorded PER-USER — each admin sees their own connection. | |
| 988 | + $account_email = sanitize_email( (string) $request->get_param( 'account_email' ) ); | |
| 989 | + $user_id = isset( $result['user_id'] ) ? (int) $result['user_id'] : 0; | |
| 990 | + Mcp_Hub::mark_attached( $account_email, $user_id ?: null ); | |
| 991 | + | |
| 992 | + // The hub only needs the credential; don't leak the internal user id. | |
| 993 | + unset( $result['user_id'] ); | |
| 994 | + return rest_ensure_response( $result ); | |
| 995 | + } | |
| 996 | + | |
| 472 | 997 | // -- OAuth 2.1 handlers ------------------------------------------------ |
| 473 | 998 | |
| 474 | 999 | /** |
| 1000 | + * GET /mcp/.well-known/oauth-protected-resource — RFC 9728 metadata. | |
| 1001 | + * | |
| 1002 | + * Byte-identical to what the /.well-known rewrite serves; both call the | |
| 1003 | + * same builder so the two locations can never drift. | |
| 1004 | + * | |
| 1005 | + * @return \WP_REST_Response | |
| 1006 | + */ | |
| 1007 | + public function rest_protected_resource_metadata(): \WP_REST_Response { | |
| 1008 | + return $this->discovery_response( Mcp_OAuth::protected_resource_metadata() ); | |
| 1009 | + } | |
| 1010 | + | |
| 1011 | + /** | |
| 1012 | + * GET /mcp/.well-known/oauth-authorization-server — RFC 8414 metadata. | |
| 1013 | + * | |
| 1014 | + * @return \WP_REST_Response | |
| 1015 | + */ | |
| 1016 | + public function rest_authorization_server_metadata(): \WP_REST_Response { | |
| 1017 | + return $this->discovery_response( Mcp_OAuth::authorization_server_metadata() ); | |
| 1018 | + } | |
| 1019 | + | |
| 1020 | + /** | |
| 1021 | + * Wrap a discovery document in a public, cacheable REST response. | |
| 1022 | + * | |
| 1023 | + * @param array<string,mixed> $data The metadata document. | |
| 1024 | + * @return \WP_REST_Response | |
| 1025 | + */ | |
| 1026 | + private function discovery_response( array $data ): \WP_REST_Response { | |
| 1027 | + $response = new \WP_REST_Response( $data, 200 ); | |
| 1028 | + $response->header( 'Cache-Control', 'public, max-age=3600' ); | |
| 1029 | + return $response; | |
| 1030 | + } | |
| 1031 | + | |
| 1032 | + /** | |
| 475 | 1033 | * POST /mcp/oauth/register — RFC 7591 dynamic client registration. |
| 476 | 1034 | * |
| 477 | 1035 | * @param \WP_REST_Request $request JSON body with redirect_uris. |
| 478 | 1036 | * @return \WP_REST_Response|\WP_Error |
| @@ -605,8 +1163,9 @@ | ||
| 605 | 1163 | $args[ $k ] = $v; |
| 606 | 1164 | } |
| 607 | 1165 | } |
| 608 | 1166 | |
| 1167 | + Mcp_Tools::set_channel( 'broker' ); | |
| 609 | 1168 | $result = Mcp_Tools::invoke( $tool, $args ); |
| 610 | 1169 | if ( is_wp_error( $result ) ) { |
| 611 | 1170 | return $result; |
| 612 | 1171 | } |
| @@ -763,8 +1322,27 @@ | ||
| 763 | 1322 | 'shortdesc' => 'Show MCP connection status and the paste-in endpoint URL.', |
| 764 | 1323 | 'synopsis' => array(), |
| 765 | 1324 | ), |
| 766 | 1325 | array( |
| 1326 | + 'name' => 'xspeed mcp activity', | |
| 1327 | + 'callback' => array( $this, 'cli_activity' ), | |
| 1328 | + 'shortdesc' => 'List recent MCP tool calls (the AI audit trail).', | |
| 1329 | + 'synopsis' => array( | |
| 1330 | + array( | |
| 1331 | + 'name' => 'limit', | |
| 1332 | + 'type' => 'assoc', | |
| 1333 | + 'optional' => true, | |
| 1334 | + 'description' => 'Maximum entries to show (default 20).', | |
| 1335 | + ), | |
| 1336 | + array( | |
| 1337 | + 'name' => 'clear', | |
| 1338 | + 'type' => 'flag', | |
| 1339 | + 'optional' => true, | |
| 1340 | + 'description' => 'Wipe the audit trail instead of listing it.', | |
| 1341 | + ), | |
| 1342 | + ), | |
| 1343 | + ), | |
| 1344 | + array( | |
| 767 | 1345 | 'name' => 'xspeed mcp connect', |
| 768 | 1346 | 'callback' => array( $this, 'cli_connect' ), |
| 769 | 1347 | 'shortdesc' => 'Generate a connection token for this site\'s MCP endpoint.', |
| 770 | 1348 | 'synopsis' => array( |
| @@ -817,8 +1395,59 @@ | ||
| 817 | 1395 | } |
| 818 | 1396 | } |
| 819 | 1397 | |
| 820 | 1398 | /** |
| 1399 | + * `wp xspeed mcp activity` — read (or clear) the AI audit trail. | |
| 1400 | + * | |
| 1401 | + * @param array $args Positional args (unused). | |
| 1402 | + * @param array $assoc --limit=<n>, --clear. | |
| 1403 | + */ | |
| 1404 | + public function cli_activity( array $args, array $assoc ): void { | |
| 1405 | + unset( $args ); | |
| 1406 | + | |
| 1407 | + if ( ! empty( $assoc['clear'] ) ) { | |
| 1408 | + if ( ! Mcp_Activity_Log::clear() ) { | |
| 1409 | + // Reached via MCP run_command — the assistant is asking to | |
| 1410 | + // erase the record of its own calls. Mcp_Activity_Log::clear() | |
| 1411 | + // declines and logs the attempt; say so plainly. | |
| 1412 | + \WP_CLI::error( 'The MCP activity log cannot be cleared from an MCP tool call. Clear it from the xSpeed dashboard or from WP-CLI on the server.' ); | |
| 1413 | + return; | |
| 1414 | + } | |
| 1415 | + \WP_CLI::success( 'MCP activity log cleared.' ); | |
| 1416 | + return; | |
| 1417 | + } | |
| 1418 | + | |
| 1419 | + $limit = isset( $assoc['limit'] ) ? (int) $assoc['limit'] : 20; | |
| 1420 | + $summary = Mcp_Activity_Log::summary(); | |
| 1421 | + $entries = Mcp_Activity_Log::entries( $limit > 0 ? $limit : 20 ); | |
| 1422 | + | |
| 1423 | + \WP_CLI::log( sprintf( '%-18s %d', 'total_calls', $summary['total'] ) ); | |
| 1424 | + \WP_CLI::log( sprintf( '%-18s %d', 'failed', $summary['failed'] ) ); | |
| 1425 | + \WP_CLI::log( sprintf( '%-18s %s', 'top_tool', '' === $summary['top_tool'] ? '-' : $summary['top_tool'] ) ); | |
| 1426 | + | |
| 1427 | + if ( empty( $entries ) ) { | |
| 1428 | + \WP_CLI::log( '' ); | |
| 1429 | + \WP_CLI::log( 'No MCP tool calls recorded yet.' ); | |
| 1430 | + return; | |
| 1431 | + } | |
| 1432 | + | |
| 1433 | + \WP_CLI::log( '' ); | |
| 1434 | + foreach ( $entries as $entry ) { | |
| 1435 | + \WP_CLI::log( | |
| 1436 | + sprintf( | |
| 1437 | + '%s %-22s %-5s %-6s %s%s', | |
| 1438 | + gmdate( 'Y-m-d H:i:s', $entry['ts'] ), | |
| 1439 | + $entry['tool'], | |
| 1440 | + $entry['scope'], | |
| 1441 | + $entry['ok'] ? 'ok' : 'FAIL', | |
| 1442 | + $entry['args'], | |
| 1443 | + '' === $entry['error'] ? '' : ' — ' . $entry['error'] | |
| 1444 | + ) | |
| 1445 | + ); | |
| 1446 | + } | |
| 1447 | + } | |
| 1448 | + | |
| 1449 | + /** | |
| 821 | 1450 | * `wp xspeed mcp connect` — mint a token and print the paste-in URL. |
| 822 | 1451 | * |
| 823 | 1452 | * @param array $args Positional args (unused). |
| 824 | 1453 | * @param array $assoc Associative args (unused). |
| @@ -861,6 +1490,27 @@ | ||
| 861 | 1490 | public function cli_disconnect( array $args, array $assoc ): void { |
| 862 | 1491 | unset( $args, $assoc ); |
| 863 | 1492 | Mcp_Pairing::disconnect(); |
| 864 | 1493 | \WP_CLI::success( 'Disconnected and revoked the MCP token.' ); |
| 1494 | + } | |
| 1495 | + | |
| 1496 | + /** | |
| 1497 | + * MCP is on when a connection token exists -- it has no `enabled` | |
| 1498 | + * setting, so the sidebar counted the AI group as empty on a site with | |
| 1499 | + * a live read-write AI connection. Reads the same | |
| 1500 | + * `Mcp_Pairing::public_status()` the CLI and the panel do, so the count | |
| 1501 | + * cannot disagree with the badge on the panel. (#363) | |
| 1502 | + */ | |
| 1503 | + public function is_active(): ?bool { | |
| 1504 | + $status = Mcp_Pairing::public_status(); | |
| 1505 | + return ! empty( $status['connected'] ); | |
| 1506 | + } | |
| 1507 | + | |
| 1508 | + /** | |
| 1509 | + * MCP has no on/off setting -- it is on when a connection exists. | |
| 1510 | + */ | |
| 1511 | + public function active_reason(): ?string { | |
| 1512 | + return $this->is_active() | |
| 1513 | + ? __( 'An AI assistant is connected to this site. This module counts as on whenever a connection token exists, rather than having its own on/off setting.', 'xspeed' ) | |
| 1514 | + : __( 'No AI assistant is connected. This module counts as on once you connect one.', 'xspeed' ); | |
| 865 | 1515 | } |
| 866 | 1516 | } |