'' === $raw ? '' : Secret_At_Rest::decrypt( $raw ), // What authorize() compares against. Held separately so a token // whose ciphertext can no longer be opened — the auth salt was // rotated, the site was migrated without wp-config — keeps // authenticating the clients already configured with it, instead of // silently locking them out. 'token_hash' => isset( $stored['token_hash'] ) ? (string) $stored['token_hash'] : '', 'connected' => ! empty( $stored['connected'] ), 'connected_at' => isset( $stored['connected_at'] ) ? (int) $stored['connected_at'] : 0, 'scopes' => isset( $stored['scopes'] ) && is_array( $stored['scopes'] ) ? array_values( array_map( 'strval', $stored['scopes'] ) ) : [], 'user_id' => isset( $stored['user_id'] ) ? (int) $stored['user_id'] : 0, 'last_used' => isset( $stored['last_used'] ) ? (int) $stored['last_used'] : 0, ]; } /** * Record that the static token was just used to authenticate an MCP call. * Throttled to at most one option write per minute so a busy client can't * turn every request into a database write. No-op when not connected. * * @return void */ public static function touch_last_used(): void { $stored = get_option( self::OPTION, [] ); if ( ! is_array( $stored ) || empty( $stored['site_token'] ) ) { return; } $now = time(); $last = isset( $stored['last_used'] ) ? (int) $stored['last_used'] : 0; if ( $now - $last < self::LAST_USED_THROTTLE ) { return; } $stored['last_used'] = $now; update_option( self::OPTION, $stored, false ); } /** * SHA-256 used to store the pairing token's verifier at rest. * * Mirrors Mcp_OAuth::hash(), which has always stored access and refresh * tokens this way. The pairing token was the one exception (#396). * * @since 2.0.1 * * @param string $value Raw token. * @return string */ private static function hash( string $value ): string { return hash( 'sha256', $value ); } /** * Whether a presented token is the pairing token. * * Compared against the stored hash. A row written before this change holds * a plaintext token and no hash, so it is verified against the plaintext * once and then upgraded in place — an existing pairing keeps working and * no one has to re-pair. * * @since 2.0.1 * * @param string $presented Token presented by the client. * @return bool */ public static function verify_token( string $presented ): bool { if ( '' === $presented ) { return false; } $state = self::state(); if ( '' !== $state['token_hash'] ) { return hash_equals( $state['token_hash'], self::hash( $presented ) ); } // Legacy row: plaintext, no hash. if ( '' === $state['site_token'] || ! hash_equals( $state['site_token'], $presented ) ) { return false; } self::upgrade_legacy_storage( $presented ); return true; } /** * Re-store a legacy plaintext token encrypted, with its hash. * * @since 2.0.1 * * @param string $token Raw token, already verified. * @return void */ private static function upgrade_legacy_storage( string $token ): void { $stored = get_option( self::OPTION, [] ); if ( ! is_array( $stored ) ) { return; } $stored['site_token'] = Secret_At_Rest::encrypt( $token ); $stored['token_hash'] = self::hash( $token ); update_option( self::OPTION, $stored, false ); } /** * The stored site token (secret). Empty string when not connected. * * @return string */ public static function site_token(): string { return self::state()['site_token']; } /** * The admin user the connection runs as (the token's minter). * * @return int */ public static function user_id(): int { return self::state()['user_id']; } /** * Whether an MCP connection token is currently active for this site. * * @return bool */ public static function is_connected(): bool { $state = self::state(); return $state['connected'] && '' !== $state['site_token']; } /** * Whether the active connection is limited to read-only tools. * * @return bool */ public static function is_read_only(): bool { $scopes = self::state()['scopes']; return ! in_array( 'write', $scopes, true ); } /** * Sanitized snapshot for the MCP admin page. * * @return array */ public static function public_status(): array { $state = self::state(); return [ 'connected' => self::is_connected(), 'connection_token' => $state['site_token'], 'connect_url' => self::connect_url(), 'mcp_endpoint' => self::site_endpoint(), 'mcp_endpoint_rest' => self::site_endpoint_fallback(), 'connected_at' => $state['connected_at'], 'last_used' => $state['last_used'], 'scopes' => $state['scopes'], 'read_only' => self::is_read_only(), // Ready-to-paste connection recipes (header-based — token stays out // of the URL, so it can't leak into server/proxy logs). 'config' => self::config_snippets(), // A drop-in instruction the user can paste into their AI client so // it sets the connection up itself. 'ai_prompt' => self::ai_prompt(), ]; } /** * Ready-to-paste connection recipes for the dashboard. All header-based * (Authorization: Bearer) so the secret stays out of URLs and logs. * Empty strings when not connected. * * @return array{cli:string,json:string} */ public static function config_snippets(): array { $token = self::site_token(); if ( '' === $token ) { return [ 'cli' => '', 'json' => '', ]; } $endpoint = self::site_endpoint(); // Claude Code one-liner. The CLI requires the positional NAME and URL // BEFORE any flags (`claude mcp add --flags`). $cli = sprintf( 'claude mcp add thinkrank %s --transport http --header "Authorization: Bearer %s"', $endpoint, $token ); // Portable mcpServers JSON block (Claude Desktop / other clients). $json = wp_json_encode( [ 'mcpServers' => [ 'thinkrank' => [ 'url' => $endpoint, 'headers' => [ 'Authorization' => 'Bearer ' . $token, ], ], ], ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); return [ 'cli' => $cli, 'json' => is_string( $json ) ? $json : '', ]; } /** * A copy-paste instruction the user hands to their AI assistant so IT * sets up the ThinkRank MCP connection for them. Empty when not connected. * * @return string */ public static function ai_prompt(): string { if ( ! self::is_connected() ) { return ''; } $token = self::site_token(); $endpoint = self::site_endpoint(); $access = self::is_read_only() ? 'read-only (inspect SEO data and settings only)' : 'read-write (can update SEO metadata and settings)'; $lines = [ 'Add the following remote MCP server to your connections so you can manage SEO on my WordPress site, then use it.', '', 'Server name: ThinkRank', 'Server URL: ' . $endpoint, 'Transport: streamable HTTP', 'Authentication: Bearer token (in the Authorization header)', 'API key: ' . $token, 'Access level: ' . $access, '', 'If you use the Claude Code CLI, this is the exact command (name and URL come BEFORE the flags):', ' ' . self::config_snippets()['cli'], '', 'Add it now, confirm it is connected by calling its "list-content-types" tool, and tell me what content types you can see.', ]; $prompt = implode( "\n", $lines ); /** * Filter the copy-paste AI setup prompt shown in the MCP page. * * @param string $prompt The default prompt text. * @param bool $read_only Whether the connection is read-only. */ return (string) apply_filters( 'thinkrank_mcp_ai_prompt', $prompt, self::is_read_only() ); } /** * Connect — mint a connection token for this site's MCP endpoint. * * Idempotent: re-connecting keeps the existing token (and its scopes) so * a paired client isn't silently broken. Use rotate() to change either. * * @param bool $read_only Grant only the `read` scope on a NEW token. * @return array Public status. */ public static function connect( bool $read_only = false ): array { $state = self::state(); $existing = '' !== $state['site_token']; $token = $existing ? $state['site_token'] : self::mint_token(); $scopes = $existing && ! empty( $state['scopes'] ) ? $state['scopes'] : self::scopes_for( $read_only ); update_option( self::OPTION, [ 'site_token' => Secret_At_Rest::encrypt( $token ), 'token_hash' => self::hash( $token ), 'connected' => true, 'connected_at' => $existing ? $state['connected_at'] : time(), 'scopes' => $scopes, 'user_id' => $existing && $state['user_id'] ? $state['user_id'] : get_current_user_id(), ], false ); return self::public_status(); } /** * Rotate — mint a BRAND-NEW token, invalidating the previous one * immediately. The leaked-token remedy. Optionally flips read-only. * * @param bool|null $read_only null = keep current scopes; true/false = set. * @return array Public status with the fresh token. */ public static function rotate( ?bool $read_only = null ): array { $state = self::state(); $scopes = null === $read_only ? ( ! empty( $state['scopes'] ) ? $state['scopes'] : self::DEFAULT_SCOPES ) : self::scopes_for( $read_only ); $token = self::mint_token(); update_option( self::OPTION, [ 'site_token' => Secret_At_Rest::encrypt( $token ), 'token_hash' => self::hash( $token ), 'connected' => true, 'connected_at' => time(), 'scopes' => $scopes, 'user_id' => get_current_user_id() ? get_current_user_id() : $state['user_id'], ], false ); return self::public_status(); } /** * Disconnect — revoke the connection token AND every OAuth grant, so * Disconnect is a single kill switch for ALL MCP access. * * @return array Public status after disconnect. */ public static function disconnect(): array { delete_option( self::OPTION ); Mcp_OAuth::revoke_all(); return self::public_status(); } /** * Map a read-only flag to the granted scope list. * * @param bool $read_only Whether to grant read-only access. * @return string[] */ private static function scopes_for( bool $read_only ): array { return $read_only ? [ 'read' ] : self::DEFAULT_SCOPES; } /** * Mint a 32-byte random token (64 hex chars). * * @return string */ private static function mint_token(): string { return bin2hex( random_bytes( 32 ) ); } }