isset( $stored['site_token'] ) ? (string) $stored['site_token'] : '', 'connection_token' => isset( $stored['connection_token'] ) ? (string) $stored['connection_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'] ) ) : array(), ); } /** The stored site token (secret). Empty string when not connected. */ public static function site_token(): string { return self::state()['site_token']; } /** Whether an MCP connection token is currently active for this site. */ public static function is_connected(): bool { $state = self::state(); return $state['connected'] && '' !== $state['site_token']; } /** * Sanitized snapshot for the dashboard panel. * * In the per-site model the token the user pastes IS the site_token * (the plugin validates it directly). We surface it as * `connection_token`. The site's own endpoint is primary; the broker * endpoint is offered only as an optional alternative. * * @return array */ public static function public_status(): array { $state = self::state(); return array( 'connected' => self::is_connected(), 'connection_token' => $state['connection_token'], // The single paste-in URL (token embedded). Convenient fallback. 'connect_url' => self::connect_url(), 'mcp_endpoint' => self::site_endpoint(), 'mcp_endpoint_rest' => self::site_endpoint_fallback(), 'broker_endpoint' => self::broker_url() . '/mcp', 'connected_at' => $state['connected_at'], 'scopes' => $state['scopes'], 'read_only' => self::is_read_only(), // Ready-to-paste connection recipes (header-based — token never in // the URL, so it can't leak into server/proxy logs). Empty when // not connected. 'config' => self::config_snippets(), // A drop-in instruction the user can paste into their AI client so // it knows what it's connected to and how to behave. 'ai_prompt' => self::ai_prompt(), // The tool catalog (name + short description + whether it writes), // so the panel can show the user exactly what the AI can do. 'tools' => self::tools_summary(), ); } /** * Compact tool catalog for the dashboard: each tool's name, a short * description, and whether it mutates state (write). Mirrors the same * catalog the MCP `tools/list` call returns, so the panel can never drift * from what an AI client actually sees. * * @return array */ public static function tools_summary(): array { if ( ! class_exists( __NAMESPACE__ . '\\Mcp_Tools' ) ) { return array(); } $out = array(); foreach ( Mcp_Tools::catalog() as $name => $spec ) { // A hidden tool is a private broker stage. This summary is the // dashboard's answer to "what can an agent do here", so listing one // would advertise it in the one place a user reads — the second // enumerator of the same catalog, and the one tools/list's own // filter does not cover. if ( ! empty( $spec['hidden'] ) ) { continue; } $out[] = array( 'name' => (string) $name, 'description' => isset( $spec['description'] ) ? (string) $spec['description'] : '', 'write' => ! empty( $spec['write'] ), ); } return $out; } /** * 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 array( 'cli' => '', 'json' => '', ); } $endpoint = self::site_endpoint(); $name = self::server_name(); // Claude Code one-liner. The CLI requires the positional NAME and URL // BEFORE any flags (`claude mcp add --flags`); putting // --transport first fails with "missing required argument 'name'". $cli = sprintf( 'claude mcp add %s %s --transport http --header "Authorization: Bearer %s"', $name, $endpoint, $token ); // Portable mcpServers JSON block (Claude Desktop / other clients). // `type: http` declares the Streamable-HTTP transport explicitly — // clients that default to stdio otherwise fail to connect. $json = wp_json_encode( array( 'mcpServers' => array( $name => array( 'type' => 'http', 'url' => $endpoint, 'headers' => array( 'Authorization' => 'Bearer ' . $token, ), ), ), ), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); return array( 'cli' => $cli, 'json' => is_string( $json ) ? $json : '', ); } /** * A per-SITE MCP server name so a user can connect MANY sites to the same * AI client without a name collision. `claude mcp add xspeed …` hardcoded * "xspeed" for every site, so the second site failed with "server xspeed * already exists". We derive `xspeed-