get_header( 'authorization' ) ? 'yes' : 'no', (string) $request->get_header( 'accept' ), substr( (string) $request->get_body(), 0, 300 ) ) ); } // The admin toggle is the master switch: off = no MCP surface at all. if ( ! Mcp_Manager::is_enabled() ) { return self::error_response( null, self::UNAUTHORIZED, 'MCP is disabled on this site. Enable it under ThinkRank → MCP.', 403 ); } // A request carrying NO credential is the normal opening move of the // OAuth flow — the client is asking for the RFC 9728 challenge, not // guessing a token. Only a credential that was PRESENTED and rejected // counts against the limiter, and only such a request can be locked // out; otherwise every OAuth-capable client walls itself off after // DEFAULT_MAX_FAILS discovery probes. $presented = self::extract_token( $request ); // Lockout check first: a rate-limited IP never reaches the compare. if ( '' !== $presented && Mcp_Rate_Limiter::is_locked() ) { $response = self::error_response( null, self::UNAUTHORIZED, 'Too many failed attempts. Try again later.', 429 ); // Keep the challenge on the 429 too: a client that only ever sees // a bare 429 concludes the server has no OAuth at all. $response->header( 'WWW-Authenticate', self::challenge_header() ); $response->header( 'Retry-After', (string) Mcp_Rate_Limiter::retry_after() ); return $response; } // Authenticate: static pairing token OR an OAuth 2.1 access token // (both Bearer). Either satisfies the gate. if ( true !== self::authorize( $request ) ) { if ( '' !== $presented ) { Mcp_Rate_Limiter::record_failure(); } $response = self::error_response( null, self::UNAUTHORIZED, 'Unauthorized: invalid or missing connection token.', 401 ); // RFC 9728 challenge: point OAuth-capable clients at the // protected-resource metadata so they can start the auth flow. $response->header( 'WWW-Authenticate', self::challenge_header() ); return $response; } Mcp_Rate_Limiter::clear(); $raw = $request->get_body(); $msg = json_decode( $raw, true ); if ( null === $msg && JSON_ERROR_NONE !== json_last_error() ) { return self::error_response( null, self::PARSE_ERROR, 'Parse error: body is not valid JSON.', 400 ); } // Batched requests: an array of messages. Handle each; drop // notification (id-less) responses per JSON-RPC. // // KEPT DELIBERATELY, not left behind by accident. The revision we // advertise in PROTOCOL_VERSION (2025-06-18) removed JSON-RPC // batching, so this is more than the spec requires — but accepting a // batch harms nobody, while refusing one would break any client still // on an older SDK that sends them. Please don't delete this as a spec // violation; that trade is the reason it is here (#488). if ( is_array( $msg ) && array_key_exists( 0, $msg ) ) { $responses = []; foreach ( $msg as $one ) { $r = self::dispatch( is_array( $one ) ? $one : [] ); if ( null !== $r ) { $responses[] = $r; } } if ( empty( $responses ) ) { return new \WP_REST_Response( null, 202 ); } return new \WP_REST_Response( $responses, 200 ); } if ( ! is_array( $msg ) ) { return self::error_response( null, self::INVALID_REQUEST, 'Invalid request.', 400 ); } $response = self::dispatch( $msg ); if ( null === $response ) { // Notification — no response body, 202 Accepted. return new \WP_REST_Response( null, 202 ); } return new \WP_REST_Response( $response, 200 ); } /** * Dispatch a single JSON-RPC message. Returns the response array, or * null for notifications (messages with no `id`). * * @param array $msg Decoded JSON-RPC message. * @return array|null */ private static function dispatch( array $msg ): ?array { $method = isset( $msg['method'] ) ? (string) $msg['method'] : ''; $id = $msg['id'] ?? null; $params = isset( $msg['params'] ) && is_array( $msg['params'] ) ? $msg['params'] : []; // Notifications (no id) get acknowledged with no response. $is_notification = ! array_key_exists( 'id', $msg ); $response = self::handle_method( $method, $id, $params, $is_notification ); // JSON-RPC 2.0: a message with no `id` is a notification and MUST NOT // be answered. Only the default branch below used to consult this, so // initialize, ping, tools/list and tools/call sent without an id all // fell through to self::result( null, ... ) and were answered with a // 200 carrying "id": null instead of the 202 with no body a // notification should get (#488). The message is still PROCESSED — // only the reply is suppressed, which is what the spec asks for. return $is_notification ? null : $response; } /** * Run one JSON-RPC method. Whether the caller wanted an answer is * dispatch()'s business, not this method's. * * @param string $method Method name. * @param mixed $id JSON-RPC id (null for a notification). * @param array $params Method params. * @param bool $is_notification Whether the message carried no id. * @return array|null */ private static function handle_method( string $method, $id, array $params, bool $is_notification ): ?array { switch ( $method ) { case 'initialize': return self::result( $id, [ 'protocolVersion' => self::PROTOCOL_VERSION, 'capabilities' => [ 'tools' => [ 'listChanged' => false ], ], 'serverInfo' => [ 'name' => 'thinkrank', 'version' => defined( 'THINKRANK_VERSION' ) ? THINKRANK_VERSION : '1.0.0', ], ] ); case 'ping': return self::result( $id, (object) [] ); case 'tools/list': $tools = Mcp_Tools::list(); // An empty list while MCP is enabled means the Abilities // runtime never loaded (broken package) — the client sees a // clean, useless connection. Leave a trail for whoever debugs // it; the admin notice and self-test carry the loud version. if ( empty( $tools ) && defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( '[TR-MCP] tools/list returned 0 tools. ' . \ThinkRank\Abilities\Abilities_Registrar::summary() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-gated diagnostic. } return self::result( $id, [ 'tools' => $tools ] ); case 'tools/call': return self::call_tool( $id, $params ); default: // notifications/initialized, notifications/cancelled, etc. if ( $is_notification || 0 === strpos( $method, 'notifications/' ) ) { return null; } return self::error( $id, self::METHOD_NOT_FOUND, 'Method not found: ' . $method ); } } /** * Execute a tools/call request and wrap the result in MCP content. * * @param mixed $id JSON-RPC id. * @param array $params { name:string, arguments:array }. * @return array */ private static function call_tool( $id, array $params ): array { $name = isset( $params['name'] ) ? (string) $params['name'] : ''; $args = isset( $params['arguments'] ) && is_array( $params['arguments'] ) ? $params['arguments'] : []; if ( '' === $name ) { return self::error( $id, self::INVALID_PARAMS, 'Missing tool name.' ); } $result = Mcp_Tools::invoke( $name, $args ); if ( is_wp_error( $result ) ) { // Tool-level failure is reported as a successful JSON-RPC // response with isError=true (per MCP), so the model can read // the message rather than the transport swallowing it. return self::result( $id, [ 'content' => [ [ 'type' => 'text', 'text' => $result->get_error_message(), ], ], 'isError' => true, ] ); } return self::result( $id, [ 'content' => [ [ 'type' => 'text', 'text' => wp_json_encode( $result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ), ], ], 'isError' => false, ] ); } // -- Auth -- /** * Validate the Bearer credential — pairing token or OAuth access token. * On success, switch the request to the granting admin's user so every * ability's own permission callback (current_user_can) still applies. * * @param \WP_REST_Request $request Incoming request. * @return bool */ private static function authorize( \WP_REST_Request $request ): bool { $presented = self::extract_token( $request ); if ( '' === $presented ) { return false; } // Path 1: the static per-site pairing token. Leave the tool scope // override cleared so Mcp_Tools defers to the pairing token's scope. // // Compared through Mcp_Pairing::verify_token(), which checks the stored // hash rather than a plaintext copy — the token is encrypted at rest and // only its hash is used to authenticate (#396). if ( Mcp_Pairing::verify_token( $presented ) ) { Mcp_Tools::set_read_only_override( null ); if ( self::impersonate( Mcp_Pairing::user_id() ) ) { // Record activity for the "Static token connections" row. Mcp_Pairing::touch_last_used(); return true; } return false; } // Path 2: an OAuth 2.1 access token minted by Mcp_OAuth. Its own // granted scope decides read-only, independent of the pairing token. $grant = Mcp_OAuth::validate_token( $presented ); if ( null !== $grant ) { Mcp_Tools::set_read_only_override( Mcp_OAuth::scope_is_read_only( $grant['scope'] ) ); return self::impersonate( $grant['user_id'] ); } return false; } /** * Run the request as the admin who granted the credential. Refuses when * the stored user no longer exists or lost manage_options — a demoted or * deleted admin's grants die with them. * * @param int $user_id Granting user id. * @return bool */ private static function impersonate( int $user_id ): bool { if ( $user_id <= 0 ) { return false; } $user = get_user_by( 'id', $user_id ); if ( ! $user || ! user_can( $user, 'manage_options' ) ) { return false; } wp_set_current_user( $user_id ); return true; } /** * The RFC 9728 WWW-Authenticate challenge value. Points the client at * this site's protected-resource metadata so an OAuth-capable client * can discover the authorization server and begin the flow. * * @return string */ private static function challenge_header(): string { // REST-served, not the /.well-known/ path-insert form: some hosts // (SiteGround) intercept root /.well-known/ at their Nginx edge and // 404 it before WordPress runs, killing the flow on the client's very // first fetch. See Mcp_OAuth::resource_metadata_url() for the full // reasoning and the override filter. return sprintf( 'Bearer resource_metadata="%s"', Mcp_OAuth::resource_metadata_url() ); } /** * Pull the token from the Authorization: Bearer header. * * @param \WP_REST_Request $request Incoming request. * @return string */ private static function extract_token( \WP_REST_Request $request ): string { $auth = $request->get_header( 'authorization' ); if ( is_string( $auth ) && preg_match( '/^Bearer\s+(.+)$/i', trim( $auth ), $m ) ) { return trim( $m[1] ); } return ''; } // -- JSON-RPC envelope helpers -- /** * Build a JSON-RPC success envelope. * * @param mixed $id JSON-RPC id. * @param mixed $result Result payload. * @return array */ private static function result( $id, $result ): array { return [ 'jsonrpc' => '2.0', 'id' => $id, 'result' => $result, ]; } /** * Build a JSON-RPC error envelope (for a single message). * * @param mixed $id JSON-RPC id. * @param int $code JSON-RPC error code. * @param string $message Error message. * @return array */ private static function error( $id, int $code, string $message ): array { return [ 'jsonrpc' => '2.0', 'id' => $id, 'error' => [ 'code' => $code, 'message' => $message, ], ]; } /** * Build a top-level error WP_REST_Response with an HTTP status. * * @param mixed $id JSON-RPC id. * @param int $code JSON-RPC error code. * @param string $message Error message. * @param int $http HTTP status. * @return \WP_REST_Response */ private static function error_response( $id, int $code, string $message, int $http ): \WP_REST_Response { return new \WP_REST_Response( self::error( $id, $code, $message ), $http ); } }