>>|null */ private ?array $assembled_cache = null; /** * Private constructor enforces the A2 singleton pattern. */ private function __construct() { } /** * Get the singleton instance (A2). * * @return self */ public static function instance(): self { if ( null === self::$_instance ) { self::$_instance = new self(); } return self::$_instance; } /** * DTO/transport category value => get_all() bucket key. * * Only 'npm' is spelled identically in both, which is why the mismatch went * unnoticed: the npm shortcode worked and the other two never did. * * @var array */ private const CATEGORY_BUCKETS = array( 'client' => 'clients', 'ai_connector' => 'ai_connectors', ); /** * Return the assembled three-category discovery result. * * Fires `acrossai_mcp_connection_methods` exactly once per request on * the composed array. Memoized: subsequent calls within the same * request return the cached result without re-firing any filter. * * @return array{npm:array>,clients:array>,ai_connectors:array>} */ public function get_all(): array { if ( null !== $this->assembled_cache ) { return $this->assembled_cache; } $assembled = array( 'npm' => $this->get_npm_methods(), 'clients' => $this->get_clients(), 'ai_connectors' => $this->get_ai_connectors(), ); /** * Filter the fully-assembled three-category discovery result. * * Consumers MAY modify the entire result (remove categories, * prepend/append DTOs, decorate `meta` fields). Return value MUST * be an array with the three required category keys — else the * return is discarded per FR-012a. * * @param array>> $assembled Pre-filter composed result. */ $filtered = apply_filters( 'acrossai_mcp_connection_methods', $assembled ); if ( ! $this->is_valid_assembled_shape( $filtered ) ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { _doing_it_wrong( 'ConnectionMethodRegistry::get_all', esc_html__( 'acrossai_mcp_connection_methods callback returned an invalid shape (must be array with npm/clients/ai_connectors keys). Falling back to pre-filter result.', 'acrossai-mcp-manager' ), '0.1.9' ); } $filtered = $assembled; } $this->assembled_cache = $filtered; return $this->assembled_cache; } /** * Return the NPM connection methods. * * Seeds with the built-in npx bridge from `NpmClientBlock::get_default_npm_method()`, * fires `acrossai_mcp_npm_methods` exactly once, drops invalid contributions * per FR-009b (key + type validation — SEC-035-001), dedups by slug * later-wins per FR-009a. * * @return array> */ public function get_npm_methods(): array { $seed = array( NpmClientBlock::get_default_npm_method() ); /** * Filter the NPM connection methods list. Consumers MAY append or * replace entries. Each entry MUST be a DTO with the six required * top-level keys AND correct value types (5 strings + `meta` array) — * else silently dropped per FR-009b. * * @param array> $methods Seeded with built-in npx bridge DTO. */ $methods = apply_filters( 'acrossai_mcp_npm_methods', $seed ); // Defensive: non-array return from a broken callback recovers with // seed. Individual malformed entries handled below by the loop. if ( ! is_array( $methods ) ) { $methods = $seed; } $dedup = array(); foreach ( $methods as $entry ) { if ( ! is_array( $entry ) || ! $this->validate_npm_dto( $entry ) ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { _doing_it_wrong( 'ConnectionMethodRegistry::get_npm_methods', esc_html__( 'acrossai_mcp_npm_methods contribution dropped: missing required key or wrong value type. DTO must have six string keys (category, slug, name, description, icon) plus meta array.', 'acrossai-mcp-manager' ), '0.1.9' ); } continue; } // Later-wins dedup by slug (FR-009a). $dedup[ $entry['slug'] ] = $entry; } return array_values( $dedup ); } /** * Return the MCP client connection methods. * * Delegates to F034 canonical enumeration; NEVER re-fires * `acrossai_mcp_client_classes` (enforced by SC-005 grep gate). * * F073 — when `$server` is provided (raw MCPServer row array with * `server_route_namespace` + `server_route` keys), each DTO carries a * pre-encoded `config` string produced by `get_config_snippet()` using * identical JSON flags to `MCPClientsBlock` so the wizard's Step 11 and * the per-server Clients tab render byte-identical Configuration JSON. * When `$server` is null, DTOs omit the `config` field entirely. * * F099 — the same `$server` branch adds `setupPrompt`, the paste-ready * instructions that tell an AI client to edit its own config file. It is * assembled here, not in either renderer: D43 requires encoding and * translation decisions to live in the DTO producer so the two surfaces * cannot drift. Consumers MUST render it as received and MUST NOT rebuild * it — reassembling in JavaScript would key the same sentences twice and * break byte-identity with the manual path. * * @param array|null $server Optional MCPServer raw row (as * returned by * `MCPServerQuery::query()[0]->to_array()`). * Null yields the server-less * discovery DTOs (backwards-compatible). * @return array> */ public function get_clients( ?array $server = null ): array { $clients = AbstractMCPClient::get_all_registered_clients(); // F073 — resolve server URL identically to MCPClientsBlock:224-226 so // the wizard's Step 11 and the per-server Clients tab produce // byte-identical Configuration JSON for every client. $server_url = null; if ( null !== $server && isset( $server['server_route_namespace'], $server['server_route'] ) ) { $server_url = rest_url( trailingslashit( (string) $server['server_route_namespace'] ) . (string) $server['server_route'] ); } $dtos = array(); foreach ( $clients as $client ) { $dto = array( 'category' => 'client', 'slug' => $client->get_client_slug(), 'name' => $client->get_client_name(), 'description' => $client->get_description(), 'icon' => $client->get_icon(), // F073 — surfaced so Step 11 can render the same instructions // callout the Clients tab shows (MCPClientsBlock:252-258). 'instructions' => $client->get_instructions(), // F075 follow-up — Step 5 in the wizard's per-client detail // tells the operator the client-specific restart / reload // action needed after pasting the config. 'restartStep' => $client->get_restart_step_text(), 'meta' => array( 'config_file' => $client->get_config_file(), // Issue #159 — per-OS paths, keyed macos/windows/linux. // ADDITIVE: `config_file` above is unchanged, so existing // consumers keep working untouched. Empty for clients whose // path is workspace-relative or a placeholder, and PARTIAL // for clients that do not build for every OS — a consumer // must read the keys present rather than assume all three. 'config_files' => $client->get_config_files(), 'top_level_key' => $client->get_top_level_key(), 'class' => get_class( $client ), ), ); if ( null !== $server_url ) { $snippet = $client->get_config_snippet( $server_url, '' ); $dto['config'] = is_array( $snippet ) ? (string) wp_json_encode( $snippet, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) : (string) $snippet; // F099 — the paste-ready setup prompt, assembled HERE rather // than in either renderer. D43 requires encoding and // translation decisions to live in the DTO producer: the PHP // tab and the React wizard must receive one finished string, or // byte-identity (FR-003) and identical translation keys cannot // both hold. Consumers render it; they never rebuild it. // // Built with an EMPTY token, exactly like `config` above, so // the DTO carries the placeholder and never a credential // (FR-008). The real value is spliced client-side only after // the operator explicitly asks for one. $dto['setupPrompt'] = $client->get_setup_prompt( $server_url, '' ); } $dtos[] = $dto; } return $dtos; } /** * Return the AI connector connection methods. * * Delegates to F021 canonical enumeration; NEVER re-fires * `acrossai_mcp_manager_connector_profiles` (enforced by SC-005 grep gate). * * @return array> */ public function get_ai_connectors(): array { /** * Filter the AI connector DTOs exposed by the Discovery API's * `ai_connector` category. * * F040 — mcp-manager no longer owns AI connector profiles or the * DTOs derived from them. A companion plugin (acrossai-pro since * 0.8.0) hooks this filter and returns DTOs built from its own * ConnectorProfileRegistry. When no companion hooks in, the default * empty array causes the `ai_connector` category to be omitted from * Discovery — no fatal, no warning. * * Each DTO must be an array shaped: * [ * 'category' => 'ai_connector', * 'slug' => string Machine identifier for the connector. * 'name' => string Human-readable display name. * 'description' => string Optional short description. * 'icon' => string Icon URL. * 'meta' => array Companion-defined metadata. * ] * * @since 0.2.6 * * @param array> $dtos Default empty array. */ return apply_filters( 'acrossai_mcp_manager_discovery_ai_connectors', array() ); } /** * Return the AI-connector step-by-step instruction HTML, keyed by connector slug. * * F082 — sibling of {@see self::get_ai_connectors()}. Different discovery * lane for a different shape: this returns `array` rather than * a list of DTOs. A companion plugin (acrossai-pro since the F082 companion * release) hooks this filter and returns the per-profile walkthrough HTML * produced by `*ConnectorProfile::get_mcp_url_setup_html()`. When no * companion hooks in, the default empty array causes Quick Connect Step 10 * to fall back to today's "DCR-only" notice — zero regression. * * ## URL sentinel token * * The discovery filter fires once per pageload with no server context, but * the actual MCP URL depends on which server the operator has selected in * the wizard. Producers therefore emit the sentinel `__ACROSSAI_MCP_URL__` * wherever the MCP URL should appear in the walkthrough; Step 10 substitutes * the sentinel with the currently-selected server's URL client-side just * before `dangerouslySetInnerHTML`. * * ## Trust boundary * * Producers guarantee the returned HTML has been through `wp_kses_post` so * consumers can treat it as render-safe. The acrossai-pro adapter enforces * this at write time; downstream consumers escape only the substituted URL. * * @since 0.3.2 * * @return array Map of connector slug → walkthrough HTML. */ public function get_ai_connector_instructions(): array { /** * Filter the per-connector walkthrough HTML surfaced in Quick Connect * Step 10. * * @since 0.3.2 * * @param array $map Default empty map. */ return apply_filters( 'acrossai_mcp_manager_discovery_ai_connector_instructions', array() ); } /** * Look up a single DTO by `(category, slug)` exact match. * * Zero validation on inputs — unknown category or unknown slug both * return `null` (developer signal). No `_doing_it_wrong`. * * @param string $category One of 'npm' / 'client' / 'ai_connector' (or anything — unknown returns null). * @param string $slug Machine identifier to match. * @return array|null */ public function find( string $category, string $slug ): ?array { $all = $this->get_all(); // get_all() keys its buckets 'npm' / 'clients' / 'ai_connectors', but a // DTO's own `category` field — and the matching F037 transport key — is // 'npm' / 'client' / 'ai_connector'. Only 'npm' spelled the same in both, // so find() silently returned null for the other two: every // [acrossai_mcp_embed category="client" slug="…"] and // category="ai_connector" shortcode rendered nothing at all. That // shortcode now lives in acrossai-pro, which calls find() with the same // two spellings, so the aliasing is still load-bearing. Accept the // DTO/transport spelling as well as the bucket spelling. $category = self::CATEGORY_BUCKETS[ $category ] ?? $category; if ( ! isset( $all[ $category ] ) ) { return null; } foreach ( $all[ $category ] as $dto ) { if ( isset( $dto['slug'] ) && $dto['slug'] === $slug ) { return $dto; } } return null; } /** * Reset the memoized `get_all()` cache. Documented supported surface — * production-shape name per B23 (never `_reset_for_tests()`). Callers: * PHPUnit `setUp()` for test isolation; companion plugins that * legitimately need mid-request filter re-registration. * * Does NOT flush F034's `get_all_registered_clients()` (not memoized in * F034) or F021's `ConnectorProfileRegistry::get_profiles()` (has its * own reset path used by OAuthTestCase). * * @return void */ public function flush_cache(): void { $this->assembled_cache = null; } /** * Validate an NPM DTO has all six required top-level keys AND that each * value has the expected type — the five string keys hold `is_string()` * values, `meta` holds `is_array()`. Direct application of FR-009b * (tightened per SEC-035-001). A key-only gate would let malicious * contributions like `slug => array()` / `meta => 'string'` reach * downstream consumers. * * @param array $dto Candidate DTO from filter contribution. * @return bool True if DTO passes shape + type validation. */ private function validate_npm_dto( array $dto ): bool { foreach ( array( 'category', 'slug', 'name', 'description', 'icon' ) as $string_key ) { if ( ! isset( $dto[ $string_key ] ) || ! is_string( $dto[ $string_key ] ) ) { return false; } } return isset( $dto['meta'] ) && is_array( $dto['meta'] ); } /** * Validate the shape returned by an `acrossai_mcp_connection_methods` * callback. Must be an array with all three required category keys, * each mapping to an array. * * @param mixed $candidate Filter return value to validate. * @return bool True if shape is valid. */ private function is_valid_assembled_shape( $candidate ): bool { if ( ! is_array( $candidate ) ) { return false; } foreach ( array( 'npm', 'clients', 'ai_connectors' ) as $category_key ) { if ( ! isset( $candidate[ $category_key ] ) || ! is_array( $candidate[ $category_key ] ) ) { return false; } } return true; } }