get_config_file() ]`: several clients' paths (`~/.claude.json` * and friends) are not macOS-specific, and labelling them so would trade * one wrong answer for another. * * Every path added here MUST be cited against the client's own upstream * documentation in the overriding method's docblock (F078 discipline). * * @since 0.3.9 * @return array OS slug => path. Empty when undeclared. */ public function get_config_files(): array { return array(); } /** * JSON/TOML top-level key the snippet gets pasted under (e.g. `mcpServers`). * Untranslated. Empty when unset. * * @return string */ public function get_top_level_key(): string { return ''; } /** * Setup instructions (translated). Rendered below the config snippet. * Empty when unset (no instructions block). * * @return string */ public function get_instructions(): string { return ''; } /** * Short, client-specific action the operator has to perform AFTER * pasting the config so the client picks up the new MCP server — * rendered as Step 5 in the Quick Connect via AcrossAI wizard's per-client detail. * * Most clients need a full restart. Some (VS Code, Cline, Kilo Code, * Roo Code) hot-reload from a sidebar or command palette. Each concrete * client should return the exact phrasing that matches its own docs. * Default is a generic "restart " so a bare subclass still gets * a sensible instruction without needing to override. * * @return string */ public function get_restart_step_text(): string { $name = $this->get_client_name(); return '' === $name ? __( 'Restart your MCP client to load the new server.', 'acrossai-mcp-manager' ) : sprintf( /* translators: %s: MCP client display name (e.g. "Claude Desktop"). */ __( 'Restart %s to load the new MCP server.', 'acrossai-mcp-manager' ), $name ); } /** * Paste-ready instructions telling an AI client to add this server to its * own configuration file. * * NON-ABSTRACT BY CONTRACT. `AbstractMCPClient` is a public extension point * — third parties register client classes through the * `acrossai_mcp_client_classes` filter — so an abstract member here would * be a breaking change to every such class. Follows the same * additive-and-defaulted precedent as `get_restart_step_text()` and * `get_config_files()`. * * The default composes accessors that already carry every per-client * difference, so no built-in client overrides it. A client wanting bespoke * wording may. * * TRANSLATION CONTRACT (FR-026): the prose is translatable; the * configuration file paths, the top-level key and the configuration block * are interpolated verbatim and never pass through translation. A localised * JSON key produces a config that silently fails, so the boundary matters. * * PLACEHOLDER CONTRACT (B16): every `sprintf()` below sees exactly ONE * placeholder style. PHP accepts positional `%s` and numbered `%1$s` in the * same format string without complaint and then mislabels the output, so * the fragments are assembled separately rather than concatenated into one * format string. * * @param string $server_url Fully-resolved MCP endpoint for one server. * @param string $auth_token Application Password, or '' for the placeholder. * @return string Plain text. Never HTML, never pre-escaped. */ public function get_setup_prompt( string $server_url, string $auth_token ): string { $name = $this->get_client_name(); $snippet = $this->get_config_snippet( $server_url, $auth_token ); // The contract allows a string (a CLI install command) or an array (a // config block). No in-tree client returns a string today, but the // union is documented and third parties may rely on it. $config = is_array( $snippet ) ? (string) wp_json_encode( $snippet, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) : (string) $snippet; $lines = array(); $lines[] = '' === $name ? __( 'I want to add this WordPress site as an MCP server to this AI client.', 'acrossai-mcp-manager' ) : sprintf( /* translators: %s: MCP client display name (e.g. "Claude Desktop"). */ __( 'I want to add this WordPress site as an MCP server to %s.', 'acrossai-mcp-manager' ), $name ); $lines[] = ''; $lines[] = __( 'Connection details:', 'acrossai-mcp-manager' ); $lines[] = sprintf( /* translators: %s: the MCP server URL. */ __( '- Server URL: %s', 'acrossai-mcp-manager' ), $server_url ); $lines[] = sprintf( /* translators: %s: WordPress username. */ __( '- Username: %s', 'acrossai-mcp-manager' ), $this->current_username() ); $lines[] = sprintf( /* translators: %s: application password, or a placeholder when none has been generated. */ __( '- Application password: %s', 'acrossai-mcp-manager' ), $this->safe_token( $auth_token ) ); $lines[] = ''; $lines[] = __( 'Please:', 'acrossai-mcp-manager' ); $step = 1; foreach ( $this->setup_prompt_file_steps() as $file_step ) { $lines[] = sprintf( /* translators: 1: step number. 2: instruction sentence. */ __( '%1$d. %2$s', 'acrossai-mcp-manager' ), $step, $file_step ); ++$step; } $top_level_key = $this->get_top_level_key(); $lines[] = sprintf( /* translators: 1: step number. 2: configuration key name, e.g. "mcpServers". */ __( '%1$d. Add this configuration to the "%2$s" block, merging it with anything already there rather than replacing the file:', 'acrossai-mcp-manager' ), $step, $top_level_key ); ++$step; $lines[] = ''; $lines[] = $config; $lines[] = ''; $lines[] = sprintf( /* translators: %d: step number. */ __( '%d. Save the file.', 'acrossai-mcp-manager' ), $step ); ++$step; $lines[] = sprintf( /* translators: 1: step number. 2: instruction, e.g. "Restart Claude Desktop to load the new MCP server." */ __( '%1$d. %2$s', 'acrossai-mcp-manager' ), $step, $this->get_restart_step_text() ); return implode( "\n", $lines ); } /** * The "open the config file" sentences, one per declared operating system. * * Every declared OS is named, labelled, with no detection of the reader's * own platform — an explicit prior review decision (#159). A client that * declares only a single project-relative path gets one sentence. * * @return string[] */ protected function setup_prompt_file_steps(): array { $per_os = OperatingSystemLabels::order( $this->get_config_files() ); if ( array() !== $per_os ) { // One path per line. An earlier revision joined them with // semicolons onto a single line; with three operating systems and // Windows' backslashes that reads as a wall and the reader cannot // tell where one path ends and the next begins. $step = array( __( 'Open or create the config file for your operating system:', 'acrossai-mcp-manager' ) ); foreach ( $per_os as $slug => $path ) { $step[] = sprintf( /* translators: 1: operating system label, e.g. "macOS". 2: file path. */ __( ' - %1$s: %2$s', 'acrossai-mcp-manager' ), OperatingSystemLabels::label( $slug ), $path ); } return array( implode( "\n", $step ) ); } $single = $this->get_config_file(); if ( '' === $single ) { return array( __( 'Open or create the config file this client uses for MCP servers.', 'acrossai-mcp-manager' ) ); } return array( sprintf( /* translators: %s: config file path. */ __( 'Open or create the config file at: %s', 'acrossai-mcp-manager' ), $single ), ); } /** * Sub-nav slot preference. Lower values sort earlier. WP-idiomatic * (matches `add_action` priority semantics). Default 100 places * third-party contributions AFTER all eight built-ins (which use * 10, 20, 30, ..., 80). Consumed by `get_all_registered_clients()` * during the sort phase; tiebreaker for equal priorities is slug ASC. * * @return int */ public function get_priority(): int { return 100; } // ───────────────────────────────────────────────────────────────────────── // F034 canonical filter-aware enumeration. Sole entry point for // "which MCP clients are registered on this site." Mirrors the shape of // ConnectorProfileRegistry::get_profiles() at // includes/Connectors/ConnectorProfileRegistry.php:57-118 (adapted for // the FQN-string contribution shape used by acrossai_mcp_client_classes). // // Seed value for the extension filter — the eight built-in clients in // insertion order. Actual runtime sort uses get_priority() per FR-010. // ───────────────────────────────────────────────────────────────────────── public const DEFAULT_CLIENT_CLASSES = array( ClaudeDesktopClient::class, ClaudeCodeClient::class, VSCodeClient::class, GitHubCopilotClient::class, CodexClient::class, CursorClient::class, GeminiClient::class, WindsurfClient::class, ZedClient::class, ClineClient::class, RooCodeClient::class, KiloCodeClient::class, AmazonQClient::class, OpenCodeClient::class, AntigravityClient::class, CustomClient::class, ); /** * Canonical enumeration of every registered MCP client. * * Fires `acrossai_mcp_client_classes` exactly once per call with * `DEFAULT_CLIENT_CLASSES` as the seed. Validates each contributed FQN * (silent-skip on invalid per SEC-013-008) and each contributed subclass * slug (regex `/\A[a-z0-9-]{1,64}\z/` with `_doing_it_wrong` under * `WP_DEBUG` for violators). Dedups by slug (later-wins). Sorts by * `(get_priority() ASC, get_client_slug() ASC)`. * * Returned instances are fresh (no caching). Consumers should call this * method once per admin render, not per lookup. * * @return AbstractMCPClient[] */ public static function get_all_registered_clients(): array { /** * Filter: acrossai_mcp_client_classes * * Companion plugins append their own AbstractMCPClient subclass FQNs. * Invalid FQNs (non-string, missing class, not extending AbstractMCPClient) * are silently skipped per SEC-013-008. Bad slugs and duplicate slugs * fire `_doing_it_wrong` under WP_DEBUG. * * @since 0.0.6 * @experimental May change without notice before 1.0.0. * * @param string[] $client_class_fqns Ordered list of AbstractMCPClient subclass FQNs. */ $class_fqns = (array) apply_filters( 'acrossai_mcp_client_classes', self::DEFAULT_CLIENT_CLASSES ); $seen = array(); foreach ( $class_fqns as $fqn ) { if ( ! is_string( $fqn ) || ! class_exists( $fqn ) ) { continue; } if ( ! is_subclass_of( $fqn, self::class ) ) { continue; } $instance = new $fqn(); $slug = $instance->get_client_slug(); if ( '' === $slug || ! preg_match( '/\A[a-z0-9-]{1,64}\z/', $slug ) ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { _doing_it_wrong( 'AcrossAI_MCP_Manager\\Includes\\MCPClients\\AbstractMCPClient::get_all_registered_clients', sprintf( /* translators: %s: rejected slug from a third-party MCP client subclass */ esc_html__( 'Client slug %s does not match /[a-z0-9-]{1,64}/ — subclass discarded.', 'acrossai-mcp-manager' ), esc_html( $slug ) ), '0.1.7' ); } continue; } if ( isset( $seen[ $slug ] ) && defined( 'WP_DEBUG' ) && WP_DEBUG ) { _doing_it_wrong( 'AcrossAI_MCP_Manager\\Includes\\MCPClients\\AbstractMCPClient::get_all_registered_clients', sprintf( /* translators: %s: duplicate slug from a third-party MCP client subclass */ esc_html__( 'Duplicate client slug %s — later contribution wins.', 'acrossai-mcp-manager' ), esc_html( $slug ) ), '0.1.7' ); } $seen[ $slug ] = $instance; } usort( $seen, static function ( AbstractMCPClient $a, AbstractMCPClient $b ): int { $priority_cmp = $a->get_priority() <=> $b->get_priority(); if ( 0 !== $priority_cmp ) { return $priority_cmp; } return $a->get_client_slug() <=> $b->get_client_slug(); } ); return array_values( $seen ); } // ───────────────────────────────────────────────────────────────────────── // Protected helpers (FR-002). // ───────────────────────────────────────────────────────────────────────── /** * Concatenate a base REST URL with a route namespace + route segment. * * Pure string composition — no get_option(), no home_url(), no WP * globals. Caller supplies the base URL (typically `rest_url()` from * the consumer's WP context); this method only joins. * * @param string $base_rest_url Base URL e.g. 'https://example.com/wp-json/'. * @param string $route_namespace Route namespace, e.g. 'mcp'. * @param string $route Route path, e.g. 'wordpress-default-server'. * * @return string Composed URL. */ protected function build_server_url( string $base_rest_url, string $route_namespace, string $route ): string { $base = rtrim( $base_rest_url, '/' ); $ns = trim( $route_namespace, '/' ); $rt = trim( $route, '/' ); if ( '' === $ns && '' === $rt ) { return $base; } if ( '' === $ns ) { return $base . '/' . $rt; } if ( '' === $rt ) { return $base . '/' . $ns; } return $base . '/' . $ns . '/' . $rt; } /** * Extract the inner mcpServers key from a server URL (Q1 2026-06-17). * * Strips query string + trailing slash, takes the last path segment, * then prefixes with the site slug (amended 2026-07-15) so the * admin-UI-rendered `mcpServers` key matches what the CLI * (`@acrossai/mcp-manager` at `configWriter.js` / `configDisplay.js:15`) * writes: `${siteSlug}-${serverId}`. Fixes the historical mismatch * where admin UI showed `mcp-adapter-default-server` but the CLI wrote * `-mcp-adapter-default-server`, causing duplicate/orphaned * entries in `~/.claude.json` when operators copied one and the CLI * generated the other. * * Site slug source: `Utilities\SiteSlug::get()` — the SAME helper * consumed by `CliController::handle_health` for the `/health` * `site_slug` field the CLI reads. Single source of truth per * constitution §VI (DRY). * * Falls back to SERVER_KEY_FALLBACK on empty / unparsable URL inputs. * * Test matrix in research.md R2 + amended 2026-07-15 for the site-slug * prefix in tests/phpunit/MCPClients/AbstractMCPClientTest.php. * * @param string $server_url Full server URL. * * @return string Derived server key of the form `-`. */ protected function derive_server_key( string $server_url ): string { $no_query = (string) strtok( $server_url, '?' ); $no_slash = rtrim( $no_query, '/' ); if ( '' === $no_slash ) { return self::SERVER_KEY_FALLBACK; } $parts = explode( '/', $no_slash ); $last = end( $parts ); if ( false === $last || '' === $last ) { return self::SERVER_KEY_FALLBACK; } return SiteSlug::get() . '-' . $last; } /** * Render the token for snippet output. Empty → placeholder text; * non-empty → verbatim. * * NEVER use this for logs — it returns plaintext. Use redact_token() * for log-safe representation. * * @param string $token Raw Application Password (may be empty). * * @return string Either the token verbatim, or the placeholder. */ protected function safe_token( string $token ): string { return '' === $token ? self::EMPTY_TOKEN_PLACEHOLDER : $token; } /** * Build the standard env block for a client's `command`/`args`/`env` shape. * * Includes `WP_API_URL` / `WP_API_USERNAME` / `WP_API_PASSWORD` and, * when the current site meets Feature 075's local-HTTPS gate (see * `LocalEnvironment::needs_tls_bypass()`), also `NODE_TLS_REJECT_UNAUTHORIZED = "0"` * — the workaround for Node.js rejecting the self-signed certs Local by * Flywheel, MAMP, DDEV, and wp-env produce. String literal `"0"` (Node * convention), not integer 0. * * `$extra` is merged FIRST so subclass keys (e.g. Claude Code's * `OAUTH_ENABLED => 'false'`) keep their historical position at the * FRONT of the env block — spec FR-006 shape-preservation contract. * The base keys always win on collision (defensive: a subclass cannot * accidentally break `WP_API_URL` / `WP_API_USERNAME` / `WP_API_PASSWORD`). * `NODE_TLS_REJECT_UNAUTHORIZED` is appended last so a diff-viewer sees * it clearly as the new key introduced by Feature 075. * * @param string $server_url Server URL. * @param string $auth_token Application Password (may be empty). * @param array $extra Additional env keys to merge in. * * @return array */ protected function build_env( string $server_url, string $auth_token, array $extra = array() ): array { $base = array( 'WP_API_URL' => $server_url, 'WP_API_USERNAME' => $this->current_username(), 'WP_API_PASSWORD' => $this->safe_token( $auth_token ), ); $env = array_merge( $extra, $base ); if ( LocalEnvironment::needs_tls_bypass() ) { $env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0'; } return $env; } /** * Return the current WP user's login for the WP_API_USERNAME env var. * * Application Passwords authenticate via HTTP Basic (username:apppass), * so every generated snippet needs both. Falls back to an empty string * when the request has no authenticated user (defensive — this method * is only called from admin render paths where a user is guaranteed). * * @return string */ protected function current_username(): string { if ( ! function_exists( 'wp_get_current_user' ) ) { return ''; } $user = wp_get_current_user(); return isset( $user->user_login ) ? (string) $user->user_login : ''; } /** * Log-safe token representation. First 4 chars + ellipsis + last 2 * chars, or '(empty)' when input is empty. * * Use this ONLY for log lines / debug strings. NEVER use it as the * actual snippet payload (FR-002 security note). * * @param string $token Raw Application Password (may be empty). * * @return string Log-safe redacted representation. */ protected function redact_token( string $token ): string { if ( '' === $token ) { return '(empty)'; } // PHP multibyte-safe substr — Application Passwords are ASCII but // belt-and-suspenders for arbitrary token strings. $prefix = mb_substr( $token, 0, 4 ); $suffix = mb_substr( $token, -2 ); return $prefix . '…' . $suffix; } }