'MCP Server', 'icon' => 'Sparkles', 'description' => 'Control this site\'s cache from Claude and other AI agents.', 'custom_panel' => 'McpPanel', ); } /** * MCP pairing state lives in xspeed_module_mcp but is managed by * Mcp_Pairing, not the schema engine. Empty schema so the base class * doesn't auto-register generic settings routes. */ public function settings_schema(): array { return array(); } /** * All MCP routes register directly (see class docblock). Returning an * empty array keeps Rest_Manager out of the token-auth path entirely. */ public function rest_routes(): array { return array(); } public function boot(): void { add_action( 'rest_api_init', array( $this, 'register_rest' ) ); // Pretty per-site endpoint: /xspeed/mcp → MCP JSON-RPC handler. add_action( 'init', array( $this, 'add_rewrite' ) ); add_filter( 'query_vars', array( $this, 'register_query_var' ) ); add_action( 'parse_request', array( $this, 'maybe_handle_pretty_endpoint' ) ); } /** * Flush rewrites once when the module first boots so /xspeed/mcp works * without a manual permalink re-save. Cheap: gated on a one-shot flag. */ public function activate(): void { $this->add_rewrite(); flush_rewrite_rules( false ); } public function deactivate(): void { flush_rewrite_rules( false ); } // -- Pretty endpoint: /xspeed/mcp -- public function add_rewrite(): void { // Token-in-URL form: /xspeed/mcp/ — a single string the user // pastes into their AI client (no separate token field). The bare // /xspeed/mcp still works with a Bearer/header token. add_rewrite_rule( '^xspeed/mcp/([a-f0-9]{64})/?$', 'index.php?' . self::QUERY_VAR . '=1&' . self::TOKEN_QUERY_VAR . '=$matches[1]', 'top' ); add_rewrite_rule( '^xspeed/mcp/?$', 'index.php?' . self::QUERY_VAR . '=1', 'top' ); // Pretty attach-callback endpoint: /xspeed/mcp/attach — the hub POSTs // the signed nonce here to verify + fetch the token. Uses the plugin's // own rewrite (consistent with the MCP URL, survives hosts that block // /wp-json). Placed BEFORE the token rule would never match "attach" // (that rule requires 64 hex chars), so ordering is safe. add_rewrite_rule( '^xspeed/mcp/attach/?$', 'index.php?' . self::ATTACH_QUERY_VAR . '=1', 'top' ); // OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place the // `.well-known` segment BEFORE the resource path, so a resource at // /xspeed/mcp is discovered at BOTH: // /.well-known/oauth-protected-resource (root form) // /.well-known/oauth-protected-resource/xspeed/mcp (path-suffixed) // Real clients (e.g. Claude Desktop) request the path-suffixed form; // serving only the root form 404s them and the connection aborts. The // optional `(?:/.*)?` tail matches both without caring about the exact // resource path (we only serve one resource). add_rewrite_rule( '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$', 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', 'top' ); // Browser-facing OAuth consent page — served OUTSIDE REST so cookie // auth (is_user_logged_in) works after the wp-login round-trip. add_rewrite_rule( '^xspeed/authorize/?$', 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', 'top' ); // Self-heal: flush once if ANY of our rules is missing from the stored // rewrite table. Checking only the first rule is not enough — a site // flushed under an older build (which had /xspeed/mcp but not the // later /xspeed/authorize + /.well-known rules) keeps that first rule, // so the guard never fires and OAuth discovery 404s forever. Guard on // the full set so any newly-added rule triggers a re-flush. $expected = array( '^xspeed/mcp/([a-f0-9]{64})/?$', '^xspeed/mcp/?$', '^xspeed/mcp/attach/?$', '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$', '^xspeed/authorize/?$', ); $rules = get_option( 'rewrite_rules' ); if ( is_array( $rules ) ) { foreach ( $expected as $rule ) { if ( ! isset( $rules[ $rule ] ) ) { flush_rewrite_rules( false ); break; } } } } /** * @param string[] $vars Registered query vars. * @return string[] */ public function register_query_var( array $vars ): array { $vars[] = self::QUERY_VAR; $vars[] = self::TOKEN_QUERY_VAR; $vars[] = self::WELLKNOWN_QUERY_VAR; $vars[] = self::AUTHORIZE_QUERY_VAR; $vars[] = self::ATTACH_QUERY_VAR; return $vars; } /** * Serve the MCP endpoint on the pretty path. Runs on parse_request so * it fires before the main query, and short-circuits WP entirely. * * @param \WP $wp The WP request object. */ public function maybe_handle_pretty_endpoint( $wp ): void { // OAuth discovery documents (served at the site root). if ( ! empty( $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] ) ) { $doc = (string) $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ]; $data = 'authorization-server' === $doc ? Mcp_OAuth::authorization_server_metadata() : Mcp_OAuth::protected_resource_metadata(); status_header( 200 ); header( 'Content-Type: application/json; charset=utf-8' ); // Discovery metadata is public + cacheable. header( 'Cache-Control: public, max-age=3600' ); echo wp_json_encode( $data ); exit; } // Pretty attach-callback: /xspeed/mcp/attach. The hub POSTs the signed // nonce; we verify it and return this site's URL + token. Auth is the // nonce itself (admin-minted, HMAC-signed), so no credential needed. if ( ! empty( $wp->query_vars[ self::ATTACH_QUERY_VAR ] ) ) { $body = json_decode( (string) file_get_contents( 'php://input' ), true ); $nonce = is_array( $body ) && isset( $body['nonce'] ) ? (string) $body['nonce'] : ''; $result = Mcp_Hub::verify_attach_nonce( $nonce ); header( 'Content-Type: application/json; charset=utf-8' ); header( 'Cache-Control: no-store' ); if ( null === $result ) { status_header( 403 ); echo wp_json_encode( array( 'error' => 'invalid_or_expired_attach_request' ) ); } else { status_header( 200 ); echo wp_json_encode( $result ); } exit; } // Browser-facing OAuth consent page (cookie auth applies here). if ( ! empty( $wp->query_vars[ self::AUTHORIZE_QUERY_VAR ] ) ) { $this->handle_authorize_page(); return; } if ( empty( $wp->query_vars[ self::QUERY_VAR ] ) ) { return; } $request = new \WP_REST_Request( 'POST', '/xspeed/v1/mcp' ); $request->set_header( 'content-type', 'application/json' ); // Carry the auth headers + raw body from the live PHP request. foreach ( array( 'authorization', Mcp_Auth::TOKEN_HEADER ) as $h ) { $val = self::server_header( $h ); if ( null !== $val ) { $request->set_header( $h, $val ); } } // Token embedded in the URL path (/xspeed/mcp/) — surface it // as the standard token header so Mcp_Server validates it the same // way. A header/Bearer token (if also sent) still takes precedence. $path_token = isset( $wp->query_vars[ self::TOKEN_QUERY_VAR ] ) ? (string) $wp->query_vars[ self::TOKEN_QUERY_VAR ] : ''; if ( '' !== $path_token && '' === (string) $request->get_header( Mcp_Auth::TOKEN_HEADER ) && '' === (string) $request->get_header( 'authorization' ) ) { $request->set_header( Mcp_Auth::TOKEN_HEADER, $path_token ); } $request->set_body( file_get_contents( 'php://input' ) ); $response = Mcp_Server::handle( $request ); $this->emit_json( $response ); } // -- REST registration -- public function register_rest(): void { // --- MCP JSON-RPC endpoint (fallback path via wp-json) ----------- // permission_callback is __return_true because Mcp_Server does its // own token auth and must reply with a JSON-RPC 401, not a bare WP // permission failure. register_rest_route( self::NS, '/mcp', array( 'methods' => 'POST', 'callback' => array( $this, 'rest_mcp' ), 'permission_callback' => '__return_true', ) ); // --- Admin-only management routes (dashboard) -------------------- register_rest_route( self::NS, '/mcp/connection', array( 'methods' => 'GET', 'callback' => array( $this, 'rest_connection' ), 'permission_callback' => array( $this, 'admin_permission' ), ) ); register_rest_route( self::NS, '/mcp/connect', array( 'methods' => 'POST', 'callback' => array( $this, 'rest_connect' ), 'permission_callback' => array( $this, 'admin_permission' ), 'args' => array( 'read_only' => array( 'type' => 'boolean', 'required' => false, 'default' => false, 'description' => 'Grant read-only access (no purge/toggle/settings changes).', ), ), ) ); register_rest_route( self::NS, '/mcp/rotate', array( 'methods' => 'POST', 'callback' => array( $this, 'rest_rotate' ), 'permission_callback' => array( $this, 'admin_permission' ), 'args' => array( 'read_only' => array( 'type' => 'boolean', 'required' => false, 'description' => 'Optionally set read-only on the new token; omit to keep current scopes.', ), ), ) ); register_rest_route( self::NS, '/mcp/access', array( 'methods' => 'POST', 'callback' => array( $this, 'rest_access' ), 'permission_callback' => array( $this, 'admin_permission' ), 'args' => array( 'read_only' => array( 'type' => 'boolean', 'required' => true, 'description' => 'Switch the live connection to read-only (true) or read & write (false), keeping the same token.', ), ), ) ); register_rest_route( self::NS, '/mcp/disconnect', array( 'methods' => 'POST', 'callback' => array( $this, 'rest_disconnect' ), 'permission_callback' => array( $this, 'admin_permission' ), ) ); // --- xSpeed Hub (multi-site) attach routes ------------------------ register_rest_route( self::NS, '/mcp/hub', array( 'methods' => 'GET', 'callback' => array( $this, 'rest_hub_status' ), 'permission_callback' => array( $this, 'admin_permission' ), ) ); register_rest_route( self::NS, '/mcp/hub/token', array( 'methods' => 'POST', 'callback' => array( $this, 'rest_hub_token' ), 'permission_callback' => array( $this, 'admin_permission' ), ) ); register_rest_route( self::NS, '/mcp/hub/attached', array( 'methods' => 'POST', 'callback' => array( $this, 'rest_hub_attached' ), 'permission_callback' => array( $this, 'admin_permission' ), 'args' => array( 'account_email' => array( 'type' => 'string', 'required' => true, 'description' => 'The hub account email this site was attached to.', ), ), ) ); register_rest_route( self::NS, '/mcp/hub/disconnect', array( 'methods' => 'POST', 'callback' => array( $this, 'rest_hub_disconnect' ), 'permission_callback' => array( $this, 'admin_permission' ), ) ); // OAuth-attach callback: the hub calls this with the signed nonce the // plugin issued. Auth is the nonce itself (no pre-shared token), so // permission_callback is open — the handler validates the nonce. register_rest_route( self::NS, '/mcp/attach', array( 'methods' => 'POST', 'callback' => array( $this, 'rest_hub_attach_callback' ), 'permission_callback' => '__return_true', 'args' => array( 'nonce' => array( 'type' => 'string', 'required' => true, 'description' => 'The signed attach nonce the plugin issued.', ), ), ) ); // --- OAuth 2.1 authorization server (the "paste a URL only" path) - // Discovery, dynamic client registration, and the token endpoint are // all public (permission enforced inside): a client must reach them // BEFORE it holds any credential. The authorize endpoint gates on a // logged-in admin inside its handler (anonymous → wp-login redirect). register_rest_route( self::NS, '/mcp/oauth/register', array( 'methods' => 'POST', 'callback' => array( $this, 'rest_oauth_register' ), 'permission_callback' => '__return_true', ) ); // NOTE: /authorize is deliberately NOT a REST route — it is served as a // normal front-end page at /xspeed/authorize (see handle_authorize_page) // so cookie auth works after the wp-login round-trip. register_rest_route( self::NS, '/mcp/oauth/token', array( 'methods' => 'POST', 'callback' => array( $this, 'rest_oauth_token' ), 'permission_callback' => '__return_true', ) ); // --- MCP-token-only tool routes (optional hosted-broker path) ---- $tool_perm = array( Mcp_Auth::class, 'permission' ); register_rest_route( self::NS, '/mcp/tool/(?P[a-z_]+)', array( array( 'methods' => 'GET', 'callback' => array( $this, 'rest_tool' ), 'permission_callback' => $tool_perm, ), array( 'methods' => 'POST', 'callback' => array( $this, 'rest_tool' ), 'permission_callback' => $tool_perm, ), ) ); } /** * Capability gate for the admin-only management routes. * * @return bool */ public function admin_permission(): bool { return current_user_can( 'manage_options' ); } // -- Handlers ---------------------------------------------------------- /** * MCP JSON-RPC over the wp-json fallback path. * * @param \WP_REST_Request $request Incoming request. * @return \WP_REST_Response */ public function rest_mcp( \WP_REST_Request $request ) { $response = Mcp_Server::handle( $request ); // Advertise the MCP protocol version on the wp-json transport too, so // both endpoints behave identically to a strict Streamable-HTTP client. $response->header( 'MCP-Protocol-Version', Mcp_Server::PROTOCOL_VERSION ); return $response; } /** * GET /mcp/connection — pairing status for the dashboard. * * @param \WP_REST_Request $request Unused. * @return \WP_REST_Response */ public function rest_connection( \WP_REST_Request $request ) { unset( $request ); return rest_ensure_response( Mcp_Pairing::public_status() ); } /** * POST /mcp/connect — mint a connection token. * * @param \WP_REST_Request $request Unused. * @return \WP_REST_Response|\WP_Error */ public function rest_connect( \WP_REST_Request $request ) { $read_only = (bool) $request->get_param( 'read_only' ); $result = Mcp_Pairing::connect( $read_only ); if ( is_wp_error( $result ) ) { return $result; } return rest_ensure_response( $result ); } /** * POST /mcp/rotate — mint a fresh token, invalidating the old one. * * @param \WP_REST_Request $request Carries optional read_only. * @return \WP_REST_Response */ public function rest_rotate( \WP_REST_Request $request ) { $read_only = null; if ( null !== $request->get_param( 'read_only' ) ) { $read_only = (bool) $request->get_param( 'read_only' ); } return rest_ensure_response( Mcp_Pairing::rotate( $read_only ) ); } /** * POST /mcp/access — change the live connection's read-only state WITHOUT * minting a new token (the paired client keeps working; only its allowed * tools change). This is what the dashboard's read-only toggle calls. * * @param \WP_REST_Request $request Carries the required read_only bool. * @return \WP_REST_Response|\WP_Error */ public function rest_access( \WP_REST_Request $request ) { $read_only = (bool) $request->get_param( 'read_only' ); $result = Mcp_Pairing::set_read_only( $read_only ); if ( is_wp_error( $result ) ) { return $result; } return rest_ensure_response( $result ); } /** * POST /mcp/disconnect — revoke the connection token. * * @param \WP_REST_Request $request Unused. * @return \WP_REST_Response */ public function rest_disconnect( \WP_REST_Request $request ) { unset( $request ); return rest_ensure_response( Mcp_Pairing::disconnect() ); } // -- xSpeed Hub (multi-site) handlers ---------------------------------- /** * GET /mcp/hub — hub-link status + the Method-1 paste-in values. * * @param \WP_REST_Request $request Unused. * @return \WP_REST_Response */ public function rest_hub_status( \WP_REST_Request $request ) { // Self-heal from the Hub (source of truth) so the connected badge is // reliable even if the attach callback never fired. Force a fresh check // when the panel asks via the X-XSpeed-Reconcile header (e.g. the admin // returned to the tab after connecting). $force = '1' === (string) $request->get_header( 'x_xspeed_reconcile' ); Mcp_Hub::reconcile_with_hub( $force ); return rest_ensure_response( Mcp_Hub::public_status() ); } /** * POST /mcp/hub/token — ensure a site_token exists and return the * paste-in values (this site's URL + token) for the hub's Add-site form. * * @param \WP_REST_Request $request Unused. * @return \WP_REST_Response */ public function rest_hub_token( \WP_REST_Request $request ) { unset( $request ); return rest_ensure_response( Mcp_Hub::generate_token() ); } /** * POST /mcp/hub/attached — record which hub account this site is * attached to (bookkeeping for the panel's status line). * * @param \WP_REST_Request $request Carries account_email. * @return \WP_REST_Response */ public function rest_hub_attached( \WP_REST_Request $request ) { $email = sanitize_email( (string) $request->get_param( 'account_email' ) ); return rest_ensure_response( Mcp_Hub::mark_attached( $email ) ); } /** * POST /mcp/hub/disconnect — clear the local hub-link bookkeeping. * * @param \WP_REST_Request $request Unused. * @return \WP_REST_Response */ public function rest_hub_disconnect( \WP_REST_Request $request ) { unset( $request ); return rest_ensure_response( Mcp_Hub::disconnect() ); } /** * POST /mcp/attach — the OAuth-attach callback. The hub presents the * signed nonce the plugin issued; on success we return this site's URL + * token so the hub can record it. Nonce is the auth (admin-minted, * HMAC-signed, time-bound), so no pre-shared token is required. * * @param \WP_REST_Request $request Carries the nonce. * @return \WP_REST_Response|\WP_Error */ public function rest_hub_attach_callback( \WP_REST_Request $request ) { $nonce = (string) $request->get_param( 'nonce' ); $result = Mcp_Hub::verify_attach_nonce( $nonce ); if ( null === $result ) { return new \WP_Error( 'xspeed_attach_invalid', __( 'Invalid or expired attach request.', 'xspeed' ), array( 'status' => 403 ) ); } // A valid nonce proves this is a real hub-initiated attach, so record it // now — the hub passes the account email so the panel can show // "Connected via ". The nonce carries the minting admin's user // id (no WP session exists in this server-to-server call), so the state // is recorded PER-USER — each admin sees their own connection. $account_email = sanitize_email( (string) $request->get_param( 'account_email' ) ); $user_id = isset( $result['user_id'] ) ? (int) $result['user_id'] : 0; Mcp_Hub::mark_attached( $account_email, $user_id ?: null ); // The hub only needs the credential; don't leak the internal user id. unset( $result['user_id'] ); return rest_ensure_response( $result ); } // -- OAuth 2.1 handlers ------------------------------------------------ /** * POST /mcp/oauth/register — RFC 7591 dynamic client registration. * * @param \WP_REST_Request $request JSON body with redirect_uris. * @return \WP_REST_Response|\WP_Error */ public function rest_oauth_register( \WP_REST_Request $request ) { $body = $request->get_json_params(); if ( ! is_array( $body ) ) { $body = array(); } $result = Mcp_OAuth::register_client( $body ); if ( is_wp_error( $result ) ) { return $result; } return new \WP_REST_Response( $result, 201 ); } /** * The browser-facing OAuth authorize page (served at /xspeed/authorize via * a rewrite, NOT the REST API — see AUTHORIZE_QUERY_VAR). Reads request * params from the superglobals because this is a normal front-end request * where cookie auth populates is_user_logged_in(). * * GET renders the consent screen (requires a logged-in admin; anonymous * users go to wp-login and return here). POST is the nonce-checked consent * submission: Approve issues a code and 302s to the client's redirect_uri; * Deny 302s back with error=access_denied. Always emits its own response * (HTML page or redirect) and exits. */ public function handle_authorize_page(): void { $is_post = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( (string) wp_unslash( $_SERVER['REQUEST_METHOD'] ) ); // Params come from GET on the consent link and POST on the form submit. // Nonce is verified below before any POST value is acted on. // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing $source = $is_post ? $_POST : $_GET; // phpcs:enable $params = array(); foreach ( array( 'client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'approve', 'deny', '_xspeed_oauth_nonce' ) as $k ) { $params[ $k ] = isset( $source[ $k ] ) ? sanitize_text_field( wp_unslash( $source[ $k ] ) ) : ''; } // Validate the OAuth params before touching the session. $req = Mcp_OAuth::validate_authorize_request( $params ); if ( is_wp_error( $req ) ) { $data = $req->get_error_data(); $redirectable = is_array( $data ) && ! empty( $data['redirectable'] ); // Only redirect the error back when redirect_uri is verified valid; // otherwise show a page (never bounce to an unverified URL). if ( $redirectable && '' !== $params['redirect_uri'] ) { $this->redirect_error( $params['redirect_uri'], $req->get_error_code(), $req->get_error_message(), $params['state'] ); } $this->emit_oauth_error_page( $req->get_error_message() ); } // Require a logged-in admin. Anonymous → wp-login, back to this URL. if ( ! is_user_logged_in() ) { $this->redirect_to_login(); } if ( ! current_user_can( 'manage_options' ) ) { $this->emit_oauth_error_page( __( 'You must be an administrator to authorize an AI agent to control this site.', 'xspeed' ) ); } // POST = consent form submitted. if ( $is_post ) { if ( ! wp_verify_nonce( $params['_xspeed_oauth_nonce'], 'xspeed_oauth_consent' ) ) { $this->emit_oauth_error_page( __( 'Security check failed. Please try connecting again.', 'xspeed' ) ); } if ( '' === $params['approve'] ) { $this->redirect_error( $req['redirect_uri'], 'access_denied', 'The user denied the request.', $req['state'] ); } $code = Mcp_OAuth::issue_code( $req, get_current_user_id() ); $this->redirect_success( $req['redirect_uri'], $code, $req['state'] ); } // GET = render the consent screen. $this->emit_consent_screen( $req ); } /** * POST /mcp/oauth/token — exchange a code (or refresh token) for tokens. * * @param \WP_REST_Request $request Form-encoded or JSON token request. * @return \WP_REST_Response */ public function rest_oauth_token( \WP_REST_Request $request ) { // Token requests are application/x-www-form-urlencoded per OAuth, but // accept JSON too. get_body_params() covers the form case. $body = $request->get_body_params(); if ( empty( $body ) ) { $json = $request->get_json_params(); $body = is_array( $json ) ? $json : array(); } $body = array_map( 'strval', $body ); $result = Mcp_OAuth::exchange_token( $body ); if ( is_wp_error( $result ) ) { $data = $result->get_error_data(); $response = new \WP_REST_Response( array( 'error' => isset( $data['error'] ) ? $data['error'] : 'invalid_request', 'error_description' => isset( $data['error_description'] ) ? $data['error_description'] : $result->get_error_message(), ), isset( $data['status'] ) ? (int) $data['status'] : 400 ); $response->header( 'Cache-Control', 'no-store' ); return $response; } $response = new \WP_REST_Response( $result, 200 ); $response->header( 'Cache-Control', 'no-store' ); $response->header( 'Pragma', 'no-cache' ); return $response; } /** * Token-authenticated tool route for the hosted broker. Maps a broker * tool call (e.g. GET /mcp/tool/get_cache_status) onto the shared * Mcp_Tools catalog, so the broker path and the JSON-RPC path never * drift. GET params + JSON body both feed the tool's arguments. */ public function rest_tool( \WP_REST_Request $request ) { $tool = (string) $request->get_param( 'tool' ); $args = $request->get_json_params(); if ( ! is_array( $args ) ) { $args = array(); } // Merge query params (e.g. ?module=minify) so GET tools work too. foreach ( $request->get_query_params() as $k => $v ) { if ( 'tool' !== $k && ! array_key_exists( $k, $args ) ) { $args[ $k ] = $v; } } $result = Mcp_Tools::invoke( $tool, $args ); if ( is_wp_error( $result ) ) { return $result; } return rest_ensure_response( $result ); } // -- Helpers -- // -- OAuth browser-response helpers ------------------------------------ /** The absolute URL of the current authorize request (for login return). */ private function current_authorize_url(): string { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- reconstructing the current URL for a login round-trip; escaped at use. $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; return home_url( $uri ); } /** Send an anonymous visitor to wp-login, returning to this authorize URL. */ private function redirect_to_login(): void { wp_safe_redirect( wp_login_url( $this->current_authorize_url() ) ); exit; } /** 302 back to the client with the authorization code (+ state). */ private function redirect_success( string $redirect_uri, string $code, string $state ): void { $args = array( 'code' => $code ); if ( '' !== $state ) { $args['state'] = $state; } // Not wp_safe_redirect: redirect_uri is a client-registered off-site // callback, already validated against the client's registered set. wp_redirect( add_query_arg( $args, $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri. exit; } /** 302 back to the client with an OAuth error (+ state). */ private function redirect_error( string $redirect_uri, string $error, string $description, string $state ): void { $args = array( 'error' => $error, 'error_description' => $description, ); if ( '' !== $state ) { $args['state'] = $state; } wp_redirect( add_query_arg( array_map( 'rawurlencode', $args ), $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri. exit; } /** * Render the consent screen. Minimal self-contained HTML (no admin * chrome — this is a client-facing OAuth page). Approve/Deny post back * to the same authorize URL with a nonce. * * @param array $req Validated authorize params. */ private function emit_consent_screen( array $req ): void { $read_only = Mcp_OAuth::scope_is_read_only( $req['scope'] ); $access = $read_only ? __( 'Read-only — inspect cache status and settings.', 'xspeed' ) : __( 'Read & write — purge caches, toggle caching, and change settings.', 'xspeed' ); $client = '' !== $req['client_name'] ? $req['client_name'] : __( 'An AI agent', 'xspeed' ); $action_url = Mcp_OAuth::authorize_url(); $nonce = wp_create_nonce( 'xspeed_oauth_consent' ); $user = wp_get_current_user(); // Preserve every OAuth param so the POST re-validates identically. $hidden = ''; foreach ( array( 'client_id', 'redirect_uri', 'code_challenge', 'scope', 'state' ) as $k ) { $val = 'scope' === $k ? $req['scope'] : ( $req[ $k ] ?? '' ); $hidden .= sprintf( '', esc_attr( $k ), esc_attr( (string) $val ) ); } // code_challenge_method + response_type are re-asserted for validation. $hidden .= ''; $hidden .= ''; status_header( 200 ); header( 'Content-Type: text/html; charset=utf-8' ); header( 'Cache-Control: no-store' ); echo '' . esc_html__( 'Authorize AI access', 'xspeed' ) . ''; echo '
'; echo '

' . esc_html__( 'Connect to xSpeed', 'xspeed' ) . '

'; /* translators: %s: AI client name. */ echo '

' . esc_html( sprintf( __( '%s wants to manage the cache on this site.', 'xspeed' ), $client ) ) . '

'; echo '
' . esc_html__( 'Site', 'xspeed' ) . '' . esc_html( wp_parse_url( home_url(), PHP_URL_HOST ) ) . '
'; echo '
' . esc_html__( 'Signed in as', 'xspeed' ) . '' . esc_html( $user->user_login ) . '
'; echo '
' . esc_html__( 'Access', 'xspeed' ) . '' . esc_html( $access ) . '
'; echo '
'; echo $hidden; // phpcs:ignore WordPress.Security.EscapeOutput -- built from esc_attr() above. echo ''; echo '
'; echo ''; echo ''; echo '
'; exit; } /** Render a standalone OAuth error page (no redirect). */ private function emit_oauth_error_page( string $message ): void { status_header( 400 ); header( 'Content-Type: text/html; charset=utf-8' ); header( 'Cache-Control: no-store' ); echo '' . esc_html__( 'Authorization error', 'xspeed' ) . ''; echo ''; echo '

' . esc_html__( 'Could not authorize', 'xspeed' ) . '

' . esc_html( $message ) . '

'; exit; } /** Read an inbound HTTP header from $_SERVER (for the pretty path). */ private static function server_header( string $name ): ?string { $key = 'HTTP_' . strtoupper( str_replace( '-', '_', $name ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- token compared constant-time downstream; raw header needed verbatim. return isset( $_SERVER[ $key ] ) ? wp_unslash( $_SERVER[ $key ] ) : null; } /** Emit a WP_REST_Response as a JSON HTTP response and stop. */ private function emit_json( \WP_REST_Response $response ): void { status_header( $response->get_status() ); // MCP Streamable HTTP: advertise the protocol version we speak so a // strict client can pin it. We answer JSON (a spec-permitted response // type); we never open an SSE stream, so no session header is needed. header( 'MCP-Protocol-Version: ' . Mcp_Server::PROTOCOL_VERSION ); // Forward any headers the handler set (notably WWW-Authenticate on a // 401, which drives the OAuth discovery flow). rest_do_request applies // these automatically; the pretty-endpoint path must do it by hand. foreach ( $response->get_headers() as $name => $value ) { header( $name . ': ' . $value ); } $data = $response->get_data(); if ( null !== $data ) { header( 'Content-Type: application/json; charset=utf-8' ); echo wp_json_encode( $data ); } exit; } // -- WP-CLI mirror -- public function cli_commands(): array { return array( array( 'name' => 'xspeed mcp status', 'callback' => array( $this, 'cli_status' ), 'shortdesc' => 'Show MCP connection status and the paste-in endpoint URL.', 'synopsis' => array(), ), array( 'name' => 'xspeed mcp connect', 'callback' => array( $this, 'cli_connect' ), 'shortdesc' => 'Generate a connection token for this site\'s MCP endpoint.', 'synopsis' => array( array( 'name' => 'read-only', 'type' => 'flag', 'optional' => true, 'description' => 'Grant read-only access (no purge/toggle/settings changes).', ), ), ), array( 'name' => 'xspeed mcp rotate', 'callback' => array( $this, 'cli_rotate' ), 'shortdesc' => 'Mint a fresh MCP token, immediately invalidating the previous one.', 'synopsis' => array( array( 'name' => 'read-only', 'type' => 'flag', 'optional' => true, 'description' => 'Make the new token read-only.', ), ), ), array( 'name' => 'xspeed mcp disconnect', 'callback' => array( $this, 'cli_disconnect' ), 'shortdesc' => 'Revoke this site\'s MCP connection token.', 'synopsis' => array(), ), ); } /** * `wp xspeed mcp status` — print connection status + endpoint URL. * * @param array $args Positional args (unused). * @param array $assoc Associative args (unused). */ public function cli_status( array $args, array $assoc ): void { unset( $args, $assoc ); $s = Mcp_Pairing::public_status(); \WP_CLI::log( sprintf( '%-18s %s', 'connected', $s['connected'] ? 'yes' : 'no' ) ); if ( $s['connected'] ) { \WP_CLI::log( sprintf( '%-18s %s', 'access', $s['read_only'] ? 'read-only' : 'read-write' ) ); \WP_CLI::log( sprintf( '%-18s %s', 'connect_url', $s['connect_url'] ) ); \WP_CLI::log( sprintf( '%-18s %s', 'scopes', implode( ',', $s['scopes'] ) ) ); } else { \WP_CLI::log( sprintf( '%-18s %s', 'mcp_endpoint', Mcp_Pairing::site_endpoint() ) ); } } /** * `wp xspeed mcp connect` — mint a token and print the paste-in URL. * * @param array $args Positional args (unused). * @param array $assoc Associative args (unused). */ public function cli_connect( array $args, array $assoc ): void { unset( $args ); $read_only = ! empty( $assoc['read-only'] ); $result = Mcp_Pairing::connect( $read_only ); if ( is_wp_error( $result ) ) { \WP_CLI::error( $result->get_error_message() ); return; } \WP_CLI::success( 'Connected' . ( Mcp_Pairing::is_read_only() ? ' (read-only).' : '.' ) . ' Paste this single URL into your AI client:' ); \WP_CLI::log( ' ' . Mcp_Pairing::connect_url() ); \WP_CLI::log( '' ); \WP_CLI::log( 'Or, header-based (token stays out of the URL):' ); \WP_CLI::log( ' ' . Mcp_Pairing::config_snippets()['cli'] ); } /** * `wp xspeed mcp rotate` — mint a new token, revoking the old one. * * @param array $args Positional args (unused). * @param array $assoc Associative args ({ read-only?:flag }). */ public function cli_rotate( array $args, array $assoc ): void { unset( $args ); $read_only = array_key_exists( 'read-only', $assoc ) ? ! empty( $assoc['read-only'] ) : null; Mcp_Pairing::rotate( $read_only ); \WP_CLI::success( 'Rotated. The previous token is now invalid. New paste-in URL:' ); \WP_CLI::log( ' ' . Mcp_Pairing::connect_url() ); } /** * `wp xspeed mcp disconnect` — revoke the connection token. * * @param array $args Positional args (unused). * @param array $assoc Associative args (unused). */ public function cli_disconnect( array $args, array $assoc ): void { unset( $args, $assoc ); Mcp_Pairing::disconnect(); \WP_CLI::success( 'Disconnected and revoked the MCP token.' ); } }