isset( $stored['site_token'] ) ? (string) $stored['site_token'] : '', '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, ]; } /** * 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'], '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' => $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 ); update_option( self::OPTION, [ 'site_token' => self::mint_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 ) ); } }