%s %s

', esc_html__( 'ThinkRank MCP: AI assistants will connect but see no tools.', 'thinkrank' ), esc_html__( 'MCP access is enabled, but the bundled Abilities runtime (dependencies/vendor) is missing from this installation — usually a plugin package built without it. Reinstall ThinkRank from wordpress.org or an official build; until then, connected AI clients get an empty tool list.', 'thinkrank' ) ); } /** * Whether the MCP integration is enabled via the admin setting. * * @return bool */ public static function is_enabled(): bool { return (bool) Settings::instance()->get( 'enable_mcp', false ); } // -- Pretty endpoint: /thinkrank/mcp -- /** * Register rewrite rules for the MCP endpoint, OAuth discovery documents, * and the browser-facing authorize page. * * @return void */ public function add_rewrite(): void { $rules = self::rewrite_rules(); foreach ( $rules as $regex => $query ) { add_rewrite_rule( $regex, $query, 'top' ); } // Self-heal: flush once if ANY of our rules is missing from the stored // rewrite table, so the endpoints work without a manual permalink // re-save (and newly added rules trigger a re-flush on upgrade). // // Checked against the same array that was just registered, never // against a second hand-maintained list. The two drifted apart once // already: #775's first pass changed the discovery regex and left the // superseded one in the list, so a rule that is never registered was // permanently "missing" and every front-end request rebuilt the whole // rewrite table. Measured at 4 regenerations across 3 page loads // against 0 before the change — silent, because a rebuilt table is // still a correct one. // // It also has to notice the opposite: a rule of ours that is stored // but no longer wanted. The bare discovery rule is registered only // while thinkrank_mcp_serve_root_discovery is on, and "missing" alone // never fires when it is switched off again, because every rule still // registered is present. The stale rule then kept claiming the bare // URL from other MCP plugins (#775) until someone re-saved // permalinks. The unwanted set is derived from the same table built // with every optional rule on, not listed, so it cannot drift either. // A flush rebuilds the table from what this request registered, so // the stale rule is gone afterwards and the check settles instead of // flushing on every request. $stored = get_option( 'rewrite_rules' ); if ( is_array( $stored ) ) { $unwanted = array_diff_key( self::rewrite_rules( true ), $rules ); $stale = array_intersect_key( $unwanted, $stored ); $missing = array_diff_key( $rules, $stored ); if ( ! empty( $missing ) || ! empty( $stale ) ) { flush_rewrite_rules( false ); } } } /** * Every rewrite rule this plugin owns, as regex => query string. * * Single source of truth for registration AND for the self-heal check, so * the two cannot describe different sets of rules. All of them register at * `'top'`; a `'bottom'` rule would never be reached, because core's own * pagename rule matches almost any path ahead of it. * * @since 2.10.0 * * @param bool|null $root_discovery Include the opt-in bare discovery rule. * Null (the default) follows * {@see self::serves_root_discovery()}; * true is how add_rewrite() learns which * optional rules exist, to retire one * that was switched off. * @return array Regex => query string. */ private static function rewrite_rules( ?bool $root_discovery = null ): array { $endpoint = preg_quote( trim( Mcp_Pairing::SITE_ENDPOINT_PATH, '/' ), '/' ); $rules = [ // Token-in-URL form: /thinkrank/mcp/ — a single string the // user pastes into their AI client (no separate token field). The // bare /thinkrank/mcp still works with a Bearer token. '^thinkrank/mcp/([a-f0-9]{64})/?$' => 'index.php?' . self::QUERY_VAR . '=1&' . self::TOKEN_QUERY_VAR . '=$matches[1]', '^thinkrank/mcp/?$' => 'index.php?' . self::QUERY_VAR . '=1', // OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place // the `.well-known` segment BEFORE the resource path, so our // resource at /thinkrank/mcp is discovered at: // /.well-known/oauth-protected-resource/thinkrank/mcp // /.well-known/oauth-authorization-server/thinkrank/mcp // The OAuth issuer is the path-based identifier // home_url('/thinkrank/mcp') (see Mcp_OAuth::issuer), so // spec-compliant clients derive exactly these URLs. '^\.well-known/oauth-(protected-resource|authorization-server)/thinkrank/mcp/?$' => 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', // The same form with a site prefix in front of our endpoint path, // which is how subdirectory multisite discovers /ca/thinkrank/mcp // (#516). The trailing path is CAPTURED rather than discarded and // the pattern only accepts a path ENDING in our own endpoint: an // earlier version matched `(/.*)?` — anything at all — and so // claimed every neighbouring MCP plugin's RFC 9728 discovery URL // alongside our own (#775). // // Declining inside the handler does not undo that, and this is the // part worth being precise about: rewrite matching happens once, in // WP::parse_request(), before the `parse_request` action our // handler runs on. By then this rule has already won and the owning // plugin's rule has not matched, so its query var is never set and // its handler never fires. Returning instead of exiting would leave // the request to die in the main query — a 404 either way, exactly // as broken for the neighbour as serving them our document was. The // rule itself has to stop matching, so normal rewrite resolution // reaches the plugin that owns the URL. // // The handler still validates what this does match: a prefix that // is not a real site on this network is declined there. '^\.well-known/oauth-(protected-resource|authorization-server)((?:/.*)?/' . $endpoint . ')/?$' => 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]&' . self::WELLKNOWN_RESOURCE_QUERY_VAR . '=$matches[2]', // Suffix form: /.well-known/... . RFC 8414 specifies the // path-INSERT form above, but the older OpenID Connect Discovery // convention appends instead, and clients built on an OIDC library // try that shape first (sometimes only that shape). Both of these // live under our own endpoint path, so neither can collide with // another plugin's discovery URLs. '^thinkrank/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$' => 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', '^thinkrank/mcp/\.well-known/openid-configuration/?$' => 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=authorization-server', // Browser-facing OAuth consent page — served OUTSIDE REST so cookie // auth (is_user_logged_in) works after the wp-login round-trip. '^thinkrank/authorize/?$' => 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', ]; if ( $root_discovery ?? self::serves_root_discovery() ) { $rules['^\.well-known/oauth-(protected-resource|authorization-server)/?$'] = 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]'; } return $rules; } /** * Whether to answer the BARE `/.well-known/oauth-*` URLs, with no resource * path after the document name. Off by default. * * That URL is not ours to answer, in two separate senses. * * It is not ours by the specs. RFC 9728 §3.1 builds a resource's metadata * URL by inserting `.well-known` before the resource's path, so the bare * form is the metadata URL for the resource `https://example.com` — the * site root. Ours is `https://example.com/thinkrank/mcp`. §3.3 requires the * `resource` in the document to equal the identifier it was fetched for, so * answering there returns a document that contradicts its own URL. RFC 8414 * §3.3 says the same of `issuer`. A client that validates is entitled to * reject it; the only clients it ever helped were those that do not. * * And it is not ours on a site with a second MCP plugin. Registered at * `'top'`, our rule did not merely enter the ordering race for that URL, it * went to the front of it — so a neighbour's client reading the bare form * got OUR resource identifier while connecting somewhere else, which is the * same RFC 9728 mismatch #775 was filed about, reached by the other URL. * Because the winner depends on registration and flush order, it also * reproduced intermittently: the same site could behave differently after * an unrelated flush. * * Nothing of ours needs it. The 401 challenge advertises * {@see Mcp_OAuth::resource_metadata_url()}, a route in our own REST * namespace that no other plugin can claim; the path-insert and * OIDC-suffix forms above cover spec-compliant and OIDC-library clients; * {@see Mcp_Self_Test} probes none of the bare URLs; and * {@see Mcp_Static_Discovery} publishes only the path-suffixed files. * * What it did cover is a client that derives the metadata URL from the host * alone, dropping the path. On a site where ThinkRank is the only MCP * plugin that client worked, and this filter is how such a site keeps it * working. It is opt-in because switching it on is a claim over a shared * URL, and only the site owner knows whether anything else wants it. * * @since 2.10.0 * * @return bool */ public static function serves_root_discovery(): bool { /** * Filter whether ThinkRank answers the bare `/.well-known/oauth-*` * discovery URLs, which carry no resource path. * * Off by default: the URL identifies the site root rather than our MCP * endpoint, and claiming it breaks any other MCP plugin on the site. * Switch it on only where ThinkRank is the sole MCP provider and a * client derives the metadata URL from the host without the path. * * @since 2.10.0 * * @param bool $serve Whether to register the bare discovery rules. */ return (bool) apply_filters( 'thinkrank_mcp_serve_root_discovery', false ); } /** * Register our query vars. * * @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::WELLKNOWN_RESOURCE_QUERY_VAR; $vars[] = self::AUTHORIZE_QUERY_VAR; return $vars; } /** * Resolve a root-form discovery request to the site that owns the resource. * * The path-suffixed and issuer-suffixed rules are already pinned to * `thinkrank/mcp`, so only the broad root-form rule can arrive here naming * something else. Three outcomes: * * - `0` — serve from the current site. That covers the bare form * (`/.well-known/oauth-authorization-server`, the whole reason the * fallback rule exists) and this site's own endpoint path. * - a blog id — a subdirectory multisite request for another site's * resource. On subdirectory multisite everything under the network root * is served by the MAIN site, so a client discovering * `/ca/thinkrank/mcp` lands here; the document has to be built from the * `/ca/` site or every endpoint in it loses the prefix. * - `null` — not ours. Another plugin's resource, an unknown site path, or * a path that merely contains ours. * * @since 2.9.0 * * @param \WP $wp The WP request object. * @return int|null Blog id to serve from, 0 for the current site, null to decline. */ private static function resolve_wellknown_target( $wp ): ?int { $requested = isset( $wp->query_vars[ self::WELLKNOWN_RESOURCE_QUERY_VAR ] ) ? trim( (string) $wp->query_vars[ self::WELLKNOWN_RESOURCE_QUERY_VAR ], '/' ) : ''; $ours = trim( Mcp_Pairing::SITE_ENDPOINT_PATH, '/' ); if ( '' === $requested || $requested === $ours ) { return 0; } if ( ! is_multisite() || ! function_exists( 'get_site_by_path' ) ) { return null; } // Whatever precedes our endpoint path is the candidate site path: // `ca/thinkrank/mcp` -> `/ca/`. Matching the tail as a whole path // segment, not a substring, so `thinkrank/mcp-other` cannot qualify. $candidate = '/' . $requested; $suffix = '/' . $ours; if ( substr( $candidate, - strlen( $suffix ) ) !== $suffix ) { return null; } $site_path = substr( $candidate, 0, - strlen( $ours ) ); $domain = self::request_domain(); if ( '' === $domain || '' === $site_path ) { return null; } $site = get_site_by_path( $domain, $site_path ); if ( ! $site ) { return null; } // get_site_by_path() walks the path segments and falls back to the // network's root site when none match, so an unknown prefix comes back // as the MAIN site rather than as nothing. Taking that at face value // reinstates the exact bug for every path that is not a real subsite: // /nope/thinkrank/mcp would be answered with the main site's document. // Require the match to be the path that was actually asked for. if ( untrailingslashit( (string) $site->path ) !== untrailingslashit( $site_path ) ) { return null; } // get_sites() applies no status filter, so a site the network has taken // out of service resolves like any other. Advertising an authorization // server for one would point a client at an endpoint that cannot serve // it. `public` is deliberately NOT checked: on multisite that flag is // search-engine visibility, not availability, and a site can reasonably // be hidden from search while still running MCP. if ( ! empty( $site->archived ) || ! empty( $site->deleted ) || ! empty( $site->spam ) ) { return null; } return (int) $site->blog_id === get_current_blog_id() ? 0 : (int) $site->blog_id; } /** * Host for a `get_site_by_path()` lookup. * * Mirrors what WordPress itself stores in `wp_blogs`: core strips only the * default ports when it resolves the current site, so a development network * running on a non-default port keeps it, and stripping every port here * would fail to match those rows. * * @since 2.9.0 * * @return string Host, or an empty string when the request carries none. */ private static function request_domain(): string { if ( empty( $_SERVER['HTTP_HOST'] ) ) { return ''; } $host = strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) ); if ( ':80' === substr( $host, -3 ) ) { return substr( $host, 0, -3 ); } if ( ':443' === substr( $host, -4 ) ) { return substr( $host, 0, -4 ); } return $host; } /** * Build a discovery document from the site that owns the resource. * * The switch is what makes the returned endpoints carry the subsite prefix, * since every URL in the document comes from `home_url()` / `rest_url()`. * Settings memoizes per setting name with no notion of which site it read * from; it clears itself on `switch_blog` (see Settings::init), which is * what stops the MCP-enabled check below answering for the previous site. * * Returns null when the owning site has MCP turned off: a site that is not * serving MCP must not advertise an authorization server for it. * * @since 2.9.0 * * @param int $blog_id Blog to build from, 0 for the current site. * @param string $doc Document type from the rewrite. * @return array|null */ private static function discovery_document_for( int $blog_id, string $doc ): ?array { $switched = false; if ( $blog_id > 0 ) { switch_to_blog( $blog_id ); $switched = true; } $data = null; try { if ( self::is_enabled() ) { $data = 'authorization-server' === $doc ? Mcp_OAuth::authorization_server_metadata() : Mcp_OAuth::protected_resource_metadata(); } } finally { // A throw between the switch and the restore would leave the rest // of the request, including shutdown hooks, running against the // wrong site. Cheap to make impossible. if ( $switched ) { restore_current_blog(); } } return $data; } /** * 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. * @return void */ 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 ] ) ) { // The path after the document type names the RESOURCE being // discovered, and it used to be discarded (#516). Resolve it to // the site that actually owns it, which on subdirectory multisite // is how /ca/thinkrank/mcp stops being answered by the main site // with endpoints that have no /ca/ in them. $target = self::resolve_wellknown_target( $wp ); if ( null === $target ) { status_header( 404 ); exit; } $data = self::discovery_document_for( $target, (string) $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] ); if ( null === $data ) { status_header( 404 ); exit; } 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; } // Browser-facing OAuth consent page (cookie auth applies here). if ( ! empty( $wp->query_vars[ self::AUTHORIZE_QUERY_VAR ] ) ) { if ( ! self::is_enabled() ) { status_header( 404 ); exit; } $this->handle_authorize_page(); return; } if ( empty( $wp->query_vars[ self::QUERY_VAR ] ) ) { return; } // Mark the request so work that only front-end traffic must not pay for // can tell this apart from an anonymous page view. An MCP call is a // deliberate admin-equivalent action, but it arrives on the pretty // /thinkrank/mcp route, so it is neither is_admin() nor REST_REQUEST // and was invisible to those checks (#764). self::$serving_request = true; $request = new \WP_REST_Request( 'POST', '/' . self::NS . '/mcp' ); $request->set_header( 'content-type', 'application/json' ); // Carry the auth header + raw body from the live PHP request. $auth = self::server_header( 'authorization' ); if ( null !== $auth ) { $request->set_header( 'authorization', $auth ); } // Token embedded in the URL path (/thinkrank/mcp/) — surface it // as a Bearer header so Mcp_Server validates it the same way. A real // Authorization header (if also sent) 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( 'authorization' ) ) { $request->set_header( 'authorization', 'Bearer ' . $path_token ); } $request->set_body( (string) file_get_contents( 'php://input' ) ); $response = Mcp_Server::handle( $request ); $this->emit_json( $response ); } // -- REST registration -- /** * Register the REST routes: the MCP JSON-RPC fallback, the admin * management routes, and the OAuth registration/token endpoints. * * @return void */ 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 + WWW-Authenticate, // not a bare WP permission failure. register_rest_route( self::NS, '/mcp', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_mcp' ], 'permission_callback' => '__return_true', ] ); // --- Admin-only management routes (the MCP page) ------------------ register_rest_route( self::NS, '/mcp/connection', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_connection' ], 'permission_callback' => [ $this, 'admin_permission' ], ] ); register_rest_route( self::NS, '/mcp/connect', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_connect' ], 'permission_callback' => [ $this, 'admin_permission' ], 'args' => [ 'read_only' => [ 'type' => 'boolean', 'required' => false, 'default' => false, 'description' => 'Grant read-only access (no SEO metadata or settings changes).', ], ], ] ); register_rest_route( self::NS, '/mcp/rotate', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_rotate' ], 'permission_callback' => [ $this, 'admin_permission' ], 'args' => [ 'read_only' => [ 'type' => 'boolean', 'required' => false, 'description' => 'Optionally set read-only on the new token; omit to keep current scopes.', ], ], ] ); register_rest_route( self::NS, '/mcp/disconnect', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_disconnect' ], 'permission_callback' => [ $this, 'admin_permission' ], ] ); // Live round-trip diagnostic for the MCP page (see #189). Admin-only; // exercises the endpoint the way an external client would. register_rest_route( self::NS, '/mcp/self-test', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_self_test' ], 'permission_callback' => [ $this, 'admin_permission' ], ] ); // Connected AI apps (see #244): list the OAuth-connected clients plus a // single combined row for the shared static token, and revoke either. register_rest_route( self::NS, '/mcp/apps', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_apps' ], 'permission_callback' => [ $this, 'admin_permission' ], ] ); register_rest_route( self::NS, '/mcp/apps/revoke', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_revoke_app' ], 'permission_callback' => [ $this, 'admin_permission' ], 'args' => [ 'client_id' => [ 'type' => 'string', 'required' => true, 'description' => 'The OAuth client_id to revoke.', ], ], ] ); // --- 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 discovery documents are ALSO served here, not only at the // /.well-known/ rewrites: hosts that resolve root /.well-known/ at // their proxy edge (SiteGround) never let those requests reach // WordPress, while /wp-json/ always arrives. The 401 challenge // advertises this route (Mcp_OAuth::resource_metadata_url), so the // flow survives on such hosts. register_rest_route( self::NS, '/mcp/oauth/protected-resource', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_oauth_discovery_resource' ], 'permission_callback' => '__return_true', ] ); register_rest_route( self::NS, '/mcp/oauth/authorization-server', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_oauth_discovery_server' ], 'permission_callback' => '__return_true', ] ); register_rest_route( self::NS, '/mcp/oauth/register', [ 'methods' => 'POST', 'callback' => [ $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 /thinkrank/authorize (see // handle_authorize_page) so cookie auth works after wp-login. register_rest_route( self::NS, '/mcp/oauth/token', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_oauth_token' ], 'permission_callback' => '__return_true', ] ); } /** * 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 ): \WP_REST_Response { $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 MCP page. * * @return \WP_REST_Response */ public function rest_connection(): \WP_REST_Response { $this->ensure_connected(); return rest_ensure_response( Mcp_Pairing::public_status() ); } /** * Self-heal: whenever the admin views the MCP page with MCP enabled, make * sure a connection token exists. New sites mint on the enable toggle (see * #244), but a site that had MCP on before that behavior shipped would have * no token; minting here — idempotent, admin-gated — keeps the connect * recipes populated without a separate "Generate token" click. * * @return void */ private function ensure_connected(): void { if ( self::is_enabled() && ! Mcp_Pairing::is_connected() ) { Mcp_Pairing::connect(); } } /** * POST /mcp/connect — mint a connection token. * * @param \WP_REST_Request $request Carries optional read_only. * @return \WP_REST_Response */ public function rest_connect( \WP_REST_Request $request ): \WP_REST_Response { $read_only = (bool) $request->get_param( 'read_only' ); return rest_ensure_response( Mcp_Pairing::connect( $read_only ) ); } /** * 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 ): \WP_REST_Response { $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/disconnect — revoke the connection token + all OAuth grants. * * @return \WP_REST_Response */ public function rest_disconnect(): \WP_REST_Response { return rest_ensure_response( Mcp_Pairing::disconnect() ); } /** * POST /mcp/self-test — run the live round-trip diagnostic (see #189). * * @return \WP_REST_Response */ public function rest_self_test(): \WP_REST_Response { return rest_ensure_response( Mcp_Self_Test::run() ); } /** * GET /mcp/apps — the "Connected AI apps" list (see #244). * * @return \WP_REST_Response */ public function rest_apps(): \WP_REST_Response { $this->ensure_connected(); return rest_ensure_response( $this->apps_payload() ); } /** * POST /mcp/apps/revoke — cut off a single OAuth-connected app. Returns the * refreshed app list so the UI updates in one round trip. (The shared static * token has no per-client identity, so it is not listed or revoked here — it * is rotated from the connect card via /mcp/rotate.) * * @param \WP_REST_Request $request Carries target + client_id. * @return \WP_REST_Response|\WP_Error */ public function rest_revoke_app( \WP_REST_Request $request ) { $client_id = (string) $request->get_param( 'client_id' ); if ( '' === $client_id ) { return new \WP_Error( 'thinkrank_missing_client_id', __( 'A client_id is required to revoke an OAuth app.', 'thinkrank' ), [ 'status' => 400 ] ); } Mcp_OAuth::revoke_client( $client_id ); return rest_ensure_response( $this->apps_payload() ); } /** * Build the "Connected AI apps" payload: the OAuth-connected clients, with * the approving admin's display name resolved. Header-based (static-token) * clients share one anonymous secret and so are not represented here. * * @return array */ private function apps_payload(): array { $oauth_apps = []; foreach ( Mcp_OAuth::connected_apps() as $app ) { $user = $app['user_id'] > 0 ? get_userdata( $app['user_id'] ) : false; $oauth_apps[] = [ 'client_id' => $app['client_id'], 'name' => $app['name'], 'read_only' => $app['read_only'], 'approved_by' => $user ? $user->display_name : __( 'Unknown user', 'thinkrank' ), 'connected_at' => $app['connected_at'], 'last_used' => $app['last_used'], ]; } return [ 'oauth_apps' => $oauth_apps, ]; } // -- OAuth 2.1 handlers ------------------------------------------------ /** * GET /mcp/oauth/protected-resource — RFC 9728 metadata via REST. * * @return \WP_REST_Response|\WP_Error */ public function rest_oauth_discovery_resource() { return $this->oauth_discovery_response( Mcp_OAuth::protected_resource_metadata() ); } /** * GET /mcp/oauth/authorization-server — RFC 8414 metadata via REST. * * @return \WP_REST_Response|\WP_Error */ public function rest_oauth_discovery_server() { return $this->oauth_discovery_response( Mcp_OAuth::authorization_server_metadata() ); } /** * Shape one discovery document response: public, cacheable, and 404 when * MCP is off — matching the /.well-known/ rewrites exactly, so a client * sees the same truth regardless of which serving path reached it. * * @param array $document Discovery metadata. * @return \WP_REST_Response|\WP_Error */ private function oauth_discovery_response( array $document ) { if ( ! self::is_enabled() ) { return new \WP_Error( 'thinkrank_mcp_disabled', __( 'MCP is disabled on this site.', 'thinkrank' ), [ 'status' => 404 ] ); } $response = new \WP_REST_Response( $document, 200 ); $response->header( 'Cache-Control', 'public, max-age=3600' ); return $response; } /** * 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 ) { if ( ! self::is_enabled() ) { return new \WP_Error( 'thinkrank_mcp_disabled', __( 'MCP is disabled on this site.', 'thinkrank' ), [ 'status' => 403 ] ); } $body = $request->get_json_params(); if ( ! is_array( $body ) ) { $body = []; } $result = Mcp_OAuth::register_client( $body ); if ( is_wp_error( $result ) ) { return $result; } return new \WP_REST_Response( $result, 201 ); } /** * 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 ): \WP_REST_Response { if ( ! self::is_enabled() ) { $response = new \WP_REST_Response( [ 'error' => 'invalid_request', 'error_description' => 'MCP is disabled on this site.', ], 403 ); $response->header( 'Cache-Control', 'no-store' ); return $response; } // 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 : []; } $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( [ '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; } // -- OAuth authorize page ------------------------------------------------ /** * The browser-facing OAuth authorize page (served at /thinkrank/authorize * via a rewrite, NOT the REST API). 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. * * @return void */ public function handle_authorize_page(): void { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- compared against a literal after strtoupper(); nothing is stored or echoed. $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, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read verbatim by oauth_param(); see its docblock for why, and where each value is validated or escaped instead. $source = $is_post ? $_POST : $_GET; // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $params = []; foreach ( [ 'client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'approve', 'deny', '_thinkrank_oauth_nonce' ] as $k ) { $params[ $k ] = self::oauth_param( $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 assistant to manage SEO on this site.', 'thinkrank' ) ); } // POST = consent form submitted. if ( $is_post ) { if ( ! wp_verify_nonce( $params['_thinkrank_oauth_nonce'], 'thinkrank_oauth_consent' ) ) { $this->emit_oauth_error_page( __( 'Security check failed. Please try connecting again.', 'thinkrank' ) ); } 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 ); } // -- OAuth browser-response helpers ------------------------------------ /** * The absolute URL of the current authorize request (for login return). * * @return string */ 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. * * @return void */ 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). * * @param string $redirect_uri Validated client redirect URI. * @param string $code Authorization code. * @param string $state Client state. * @return void */ private function redirect_success( string $redirect_uri, string $code, string $state ): void { $args = [ '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). * * @param string $redirect_uri Validated client redirect URI. * @param string $error OAuth error code. * @param string $description Human-readable description. * @param string $state Client state. * @return void */ private function redirect_error( string $redirect_uri, string $error, string $description, string $state ): void { $args = [ '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. * @return void */ private function emit_consent_screen( array $req ): void { $read_only = Mcp_OAuth::scope_is_read_only( $req['scope'] ); $access_label = $read_only ? __( 'Read-only', 'thinkrank' ) : __( 'Read & write', 'thinkrank' ); $access_desc = $read_only ? __( 'Review your SEO across posts and site settings metadata, schema, sitemaps, robots, social, and SEO scores. No changes are made.', 'thinkrank' ) : __( 'Read and improve your SEO across posts and site settings metadata, schema, sitemaps, robots, social, indexing, and SEO scores.', 'thinkrank' ); $client = '' !== $req['client_name'] ? $req['client_name'] : __( 'An AI assistant', 'thinkrank' ); $action_url = Mcp_OAuth::authorize_url(); $nonce = wp_create_nonce( 'thinkrank_oauth_consent' ); $user = wp_get_current_user(); // Preserve every OAuth param so the POST re-validates identically. $hidden = ''; foreach ( [ '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' ); $host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); $logo = ''; $lock = ''; echo '' . esc_html__( 'Authorize AI access', 'thinkrank' ) . ''; echo '
'; echo '
ThinkRank
'; // phpcs:ignore WordPress.Security.EscapeOutput -- static markup. echo '

' . esc_html__( 'Connect to ThinkRank', 'thinkrank' ) . '

'; $sub = sprintf( /* translators: %s: AI client name, already escaped and wrapped in . */ esc_html__( '%s wants to manage SEO on this site.', 'thinkrank' ), '' . esc_html( $client ) . '' ); echo '

' . $sub . '

'; // phpcs:ignore WordPress.Security.EscapeOutput -- static translation; client name esc_html'd. echo '
'; echo '
' . esc_html__( 'Site', 'thinkrank' ) . '' . esc_html( $host ) . '
'; echo '
' . esc_html__( 'Signed in as', 'thinkrank' ) . '' . esc_html( $user->user_login ) . '
'; echo '
' . esc_html__( 'Access', 'thinkrank' ) . '
'; echo '' . esc_html( $access_label ) . ''; echo '
' . esc_html( $access_desc ) . '
'; echo '
'; echo '

' . $lock . '' . esc_html__( 'Secured with OAuth. Revoke anytime in ThinkRank → MCP.', 'thinkrank' ) . '

'; // phpcs:ignore WordPress.Security.EscapeOutput -- static icon; text esc_html'd. 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). * * @param string $message Error message. * @return void */ 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', 'thinkrank' ) . ''; echo ''; echo '

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

' . esc_html( $message ) . '

'; exit; } // -- Helpers -- /** * Read one /authorize parameter verbatim. * * Deliberately NOT sanitize_text_field(). That function exists to make * untrusted text safe to store and display, and part of what it does is * strip %XX sequences as an anti-obfuscation measure. Applied to an OAuth * protocol value it quietly changes the value's meaning. * * The concrete failure: registration stores redirect_uris raw from a JSON * body, but at /authorize the same URI arrives as a query parameter, so * PHP has already URL-decoded it — and sanitising then removed the percent * sequences. A client registered with `.../cb?next=%2Fdashboard` was * compared as `.../cb?next=dashboard`, failed the strict match, and was * told `invalid_redirect_uri` for sending exactly what it registered * (#487). `state` has the same problem: it is opaque to us and must * round-trip byte for byte, or the client aborts its own callback. * * Protocol identifiers want validation and rejection, not cleaning. Every * value read here is constrained somewhere better suited to it: * - redirect_uri strict in_array() against the client's registered set * - client_id must resolve to a registered client * - response_type must equal 'code' * - code_challenge_method must equal 'S256' * - code_challenge validated against the RFC 7636 character set * - scope intersected with SUPPORTED_SCOPES * - state opaque; escaped at output (esc_attr / rawurlencode) * - approve/deny tested for emptiness only * - the nonce passed to wp_verify_nonce() * * An array value (`?state[]=x`) reads as absent rather than becoming the * string "Array". * * @since 2.1.0 * * @param array $source $_GET or $_POST. * @param string $key Parameter name. * @return string */ private static function oauth_param( array $source, string $key ): string { if ( ! isset( $source[ $key ] ) || ! is_scalar( $source[ $key ] ) ) { return ''; } return (string) wp_unslash( $source[ $key ] ); } /** * Read an inbound HTTP header from $_SERVER (for the pretty path). * * Mirrors WP_REST_Server::get_headers(): on Apache with CGI/FastCGI/suPHP the * Authorization header never lands in HTTP_AUTHORIZATION. WordPress's own * .htaccess passthrough re-publishes it as REDIRECT_HTTP_AUTHORIZATION, and a * few Apache module setups populate neither key but do answer getallheaders(). * The REST route gets this handling from core; the pretty route builds its own * WP_REST_Request, so it has to do the same here or it 401s on those hosts. * * @param string $name Header name. * @return string|null */ 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. if ( isset( $_SERVER[ $key ] ) && '' !== $_SERVER[ $key ] ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- as above. return wp_unslash( $_SERVER[ $key ] ); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- as above. if ( isset( $_SERVER[ 'REDIRECT_' . $key ] ) && '' !== $_SERVER[ 'REDIRECT_' . $key ] ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- as above. return wp_unslash( $_SERVER[ 'REDIRECT_' . $key ] ); } if ( function_exists( 'getallheaders' ) ) { $headers = getallheaders(); if ( is_array( $headers ) ) { foreach ( $headers as $header => $value ) { if ( 0 === strcasecmp( (string) $header, $name ) && '' !== (string) $value ) { return (string) $value; } } } } return null; } /** * Emit a WP_REST_Response as a JSON HTTP response and stop. * * @param \WP_REST_Response $response Response to emit. * @return void */ 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 ); // Never cached. The pretty endpoint can carry the pairing token in its // path, so a shared cache or proxy holding a response keyed on that URL // would keep an admin-equivalent credential in its store (#396). header( 'Cache-Control: no-store, private' ); // Forward any headers the handler set (notably WWW-Authenticate on a // 401, which drives the OAuth discovery flow). foreach ( $response->get_headers() as $name => $value ) { // Re-assert the status on every header: PHP special-cases // WWW-Authenticate and forces a 401 when no status is given, // which would silently mask the 429 lockout response. header( $name . ': ' . $value, true, $response->get_status() ); } $data = $response->get_data(); if ( null !== $data ) { header( 'Content-Type: application/json; charset=utf-8' ); echo wp_json_encode( $data ); } exit; } }