/** * `McpClientPool` — owns the connected `@modelcontextprotocol/sdk` `Client` * instances for remote (Streamable HTTP / SSE) and stdio MCPs registered via * `client.qar.registerMcp`. The SDK does not invent a wheel here — every * piece of MCP protocol mechanics (JSON-RPC framing, initialize handshake, * capability negotiation, tool listing, tool invocation) is owned by the * canonical MCP TypeScript SDK; this pool is the thin facade that maps the * SDK's `McpConfig` discriminator onto the right `Transport` constructor, * holds the live `Client`, and dispatches `callTool` round-trips. * * **Lifecycle.** * - `register(name, config)` is sync — it instantiates the right transport * + `Client`, kicks off `client.connect(transport)` in the background, * and stores the in-flight `Promise` so `ready(name)` / * `callTool(...)` can await it. Errors during connect surface as a * typed {@link QodoMcpClientError} on the first awaited call. * - `removeAndClose(name)` is fire-and-forget — closes the underlying * `Client` if connected, awaits the in-flight connect if any, and * drops the entry. Idempotent. * - `dispose()` closes every entry in parallel. Called from * `QodoClient.disconnect()`. * * **Tool catalog.** Each entry caches the result of `Client.listTools()` * during connect. `resolveMcpForTool(name)` lets the SDK's generic * `tool.request` bridge map a `ToolCall.name` string onto the owning MCP * — accepting both bare names (`'search_qodo_documentation'`) and * server-prefixed forms (`'qodo-docs.search_qodo_documentation'`, * `'qodo-docs:search_qodo_documentation'`, `'qodo-docs/...'`). The * prefix form is verified against the registered MCP set, but the suffix * is not validated against the cached catalog — `callTool` round-trips * the canonical "unknown tool" surface from the MCP itself. * * **Header substitution.** `${ENV_VAR}` references inside header values * (and stdio `env` map values) are resolved against `process.env` at * connect time. Preserved verbatim from SDK1's `mcpServers` ergonomic. * * **No backwards-compat shims.** This is the 2.x clean-slate path — * the SDK does not adapt SDK1 configs at runtime. Consumers wiring * SDK1 configs map them onto the new transport names at the call * site. */ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { RemoteSseMcpConfig, RemoteStreamableHttpMcpConfig, StdioMcpConfig } from '../QarRegistryClient.js'; /** * Pool-internal canonical shape — what the pool's `prepareEntry` and * `register` accept. Excludes both the in-process `'sdk'` config (which * never reaches the pool) AND the SDK1 URL-only ergonomic * (`UrlOnlyMcpConfig` — normalized to `'remote-streamable-http'` by * `QarRegistryClient.registerMcp` before reaching the pool). Keeping the * type narrow here means `prepareEntry` doesn't have to defensively * branch on the consumer-side ergonomic. */ export type PoolableMcpConfig = StdioMcpConfig | RemoteStreamableHttpMcpConfig | RemoteSseMcpConfig; /** * Output of {@link McpClientPool.prepareEntry} — the synchronously- * constructed `Client` + `Transport` pair that hasn't been fired up yet. * Pass to {@link McpClientPool.commitEntry} to install + start the * background connect. Discarding a prepared entry without committing * leaks nothing — neither side has any I/O bound to it yet. */ export interface PreparedEntry { readonly client: Client; readonly transport: Transport; readonly transportLabel: 'remote-streamable-http' | 'remote-sse' | 'stdio'; } /** * Tool catalog entry — mirrors the MCP SDK's `Tool` shape, kept narrow. * * The fields beyond `name` flow through to the MCP projection layer's * `DiscoveredMcpTool` (consumer-facing Shape C filter input) and on into * the projected `FunctionToolDef` shipped to QAR. `inputSchema` is the * JSON Schema for call arguments; `outputSchema` mirrors the MCP * 2025-06-18 addition and is `undefined` on older servers; `annotations` * carries the upstream advisory hints (`readOnlyHint`, `destructiveHint`, * `idempotentHint`, `openWorldHint`, `title`). */ export interface ToolListEntry { readonly name: string; readonly description?: string; readonly inputSchema?: { readonly [key: string]: unknown; }; readonly outputSchema?: { readonly [key: string]: unknown; }; readonly annotations?: { readonly title?: string; readonly readOnlyHint?: boolean; readonly destructiveHint?: boolean; readonly idempotentHint?: boolean; readonly openWorldHint?: boolean; }; } export declare class McpClientPool { private readonly entries; /** * Register a remote / stdio MCP. Sync — the actual `Client.connect` runs * in the background; the `entry.connect` promise is the seam every * dependent surface (`ready`, `callTool`, `listTools`) awaits. * * **Atomic register.** {@link prepareEntry} constructs the `Client` + * `Transport` (throws synchronously on bad URL) and stores no state; * {@link commitEntry} installs the prepared entry into the pool and * fires the background connect. Callers needing to sequence the * prepare-then-commit cycle around their own state mutations should use * the two-phase form (`QarRegistryClient.registerMcp` does this so a * synchronous prepare throw never leaves the registry / pool / count * out of sync). The single-call form is kept for ad-hoc callers * (tests, future direct-pool consumers) — it just runs prepare + * commit in sequence. * * Throws synchronously on the supported discriminators only (the * `transport: 'sdk'` case never reaches here — `QarRegistryClient` routes * those through the in-process handler). On any other discriminator the * caller already passed a typescript-impossible shape; surface a runtime * error rather than silently storing a never-callable entry. */ register(name: string, config: PoolableMcpConfig): void; /** * Two-phase commit, phase 1 — construct `Client` + `Transport` without * mutating pool state. Throws synchronously on bad URL / unsupported * transport. Cheap to discard if the caller's surrounding validation * fails after this returns: nothing is fired up yet. */ prepareEntry(name: string, config: PoolableMcpConfig): PreparedEntry; /** * Two-phase commit, phase 2 — install the prepared entry into the pool * and fire the background connect. Never throws (the only failure mode * was synchronous and already surfaced from {@link prepareEntry}). * Overwrites any same-named entry without closing it; callers wanting * close-then-replace semantics should call {@link removeAndClose} * before this. */ commitEntry(name: string, prepared: PreparedEntry): void; /** * Resolve when the MCP's connect + tool-catalog pre-fetch have finished. * Resolves immediately for unknown names (mirrors the idempotency posture * of `removeAndClose`); rejects with `QodoMcpClientError` if connect * failed. */ ready(name: string): Promise; /** * Return the cached tool catalog. Awaits any in-flight connect. * `undefined` for unknown names; throws `QodoMcpClientError` when the * pool entry was closed before catalog resolution. */ listTools(name: string): Promise; /** * Invoke `Client.callTool` against the named MCP. Awaits any in-flight * connect. Errors: * - Unknown MCP name → `QodoMcpClientError` (consumer registered the * name with `transport: 'sdk'`, then asked the pool to dispatch — * shouldn't happen via the SDK's own bridge, but defensive). * - Connect failure → `QodoMcpClientError` (propagated from the * stored `entry.connect` Promise). * - `callTool` transport failure → `QodoMcpToolError` with the * underlying error on `.cause`. * - MCP envelope `isError: true` → `QodoMcpToolError` with `mcpResult` * populated so callers can read the per-content array. */ callTool(name: string, toolName: string, args: { readonly [key: string]: unknown; }): Promise<{ readonly [key: string]: unknown; }>; /** * Resolve a `ToolCall.name` string to the owning MCP + bare tool name. * Prefix forms (`mcp.tool`, `mcp:tool`, `mcp/tool`) win over bare-name * scanning when the prefix matches a registered MCP — disambiguates * collisions when two MCPs advertise the same tool. Bare matching * scans in registration order. * * `opts.isBridgeable` is an optional per-MCP gate the registry passes so * discovery-only MCPs (`registerMcp(..., { bridge: false })`) are excluded * from auto-bridge routing while still appearing in the pool for schema * projection + direct `callTool`. When supplied, an MCP for which the * predicate returns `false` is skipped: in the prefix path an explicit * `mcp.tool` naming a non-bridgeable MCP resolves to `undefined` (defer * to the consumer), and in the bare-name scan the loop continues past it * so a same-name collision still routes to a bridge-enabled MCP. Omitting * the predicate (or returning `true`) preserves the prior behavior. */ resolveMcpForTool(toolNameOrPrefixed: string, opts?: { readonly isBridgeable?: (mcpName: string) => boolean; }): { readonly mcpName: string; readonly toolName: string; } | undefined; /** * Close + drop the named entry. Idempotent on unknown names. Awaits the * in-flight connect (best-effort — a still-connecting client must be * given the chance to land before its `close()` works correctly) but * never rethrows: close errors are logged via `console.error` with the * `[@qodo/sdk]` breadcrumb prefix so structured-logging consumers see * them, but the calling surface (`unregisterMcp`, `replace: true` flow, * `dispose`) keeps its sync-ish contract. */ removeAndClose(name: string): void; /** * Close every entry. Awaitable so `QodoClient.disconnect()` can sequence * MCP teardown ahead of the WS close span's completion (the connect * promises are awaited; close calls run in parallel). */ dispose(): Promise; /** * Await every pending MCP's `connect + listTools` cycle. Used by the * `tool.request` bridge before falling back to "unresolved" so a tool * call arriving immediately after `registerMcp` (before the background * connect populates the cached catalog) doesn't race-lose against a * legitimate bare-name lookup. * * Failures on a per-MCP `connect` are swallowed (the resolver only * needs the catalog to populate or fail definitively; it doesn't care * about the failure mode — the next dispatch will surface the typed * error via {@link callTool}). Resolves quickly when every entry * already has a populated `tools` field. * * `timeoutMs` bounds the wait so a slow / unreachable MCP doesn't * strand the dispatch hot path. If catalog population doesn't complete * within the budget the resolver proceeds with whatever it currently * has (typically: marks the call unresolved → bridge emits a * deterministic error response). Default 250ms — short enough to keep * the dispatch latency contract, long enough that a fast LAN MCP * populates well inside the window. */ waitForCatalogsToSettle(timeoutMs?: number): Promise; private buildTransport; private buildStreamableHttpTransport; private buildSseTransport; private buildStdioTransport; private startConnect; /** * Tool-catalog refresh helper. Re-fires `client.listTools()` against * the same pool entry and replaces the cached catalog if the entry * is still the one we're filling (identity check). Used by the * `notifications/tools/list_changed` handler installed in * {@link startConnect}. * * Identity-checked twice: once to find the entry before the round- * trip (so a fast `removeAndClose` between notification and refresh * skips the call entirely), and once after to avoid corrupting a * post-replace entry with the prior client's results. Same pattern * as the initial connect-time pre-fetch. */ private refreshToolsForEntry; } //# sourceMappingURL=McpClientPool.d.ts.map