P0.9.1 — ν MCP Bridge — Contract
Step 2 of the 5-step chain. Behavioral contract, type shapes, retry semantics, and acceptance-criteria → test mapping. Gates Step 3 (packet) and, transitively, Step 4 (implementation).
1. Core types
/**
* A connected bridge to an external MCP server.
* Wraps a Client + Transport pair. Callers obtain one via connectToServer().
*/
export interface McpBridge {
/** The underlying SDK client. Exposed for advanced use; prefer callTool(). */
readonly client: Client;
/** URL used to create the bridge. Useful for logging / error messages. */
readonly url: string;
/** Disconnect and release the transport. Safe to call multiple times. */
close(): Promise<void>;
}
/**
* Factory that creates a Transport connected to the given URL.
* Injectable seam for tests (use InMemoryTransport) and prod (StreamableHTTPClientTransport).
*/
export type TransportFactory = (url: string) => Transport;
/**
* Options for connectToServer().
*/
export interface ConnectOptions {
/**
* Override the transport factory. When omitted, uses the default factory which
* creates StreamableHTTPClientTransport(new URL(url)).
*/
transportFactory?: TransportFactory;
/**
* Timeout in ms for the initial connect handshake.
* Defaults to COLIBRI_MCP_TIMEOUT (env) or 30000 ms.
*/
timeoutMs?: number;
/**
* Override process.env for config lookups. Defaults to process.env.
* Useful for tests that set COLIBRI_MCP_TIMEOUT.
*/
env?: NodeJS.ProcessEnv;
/** Logger override (defaults to console.error — stderr only). */
logger?: (...args: unknown[]) => void;
}
/**
* Options for callTool().
*/
export interface CallToolOptions {
/**
* Per-call timeout in ms. Overrides the bridge default.
* Defaults to COLIBRI_MCP_TIMEOUT (env) or 30000 ms.
*/
timeoutMs?: number;
/** Max retry attempts on transient errors. Defaults to 3. */
maxAttempts?: number;
/**
* Override process.env for config lookups. Defaults to process.env.
*/
env?: NodeJS.ProcessEnv;
/** Logger override (defaults to console.error — stderr only). */
logger?: (...args: unknown[]) => void;
}
2. Public API signatures
/**
* Connect to an external MCP server at `url`.
* Creates a new SDK Client, attaches a transport, and performs the MCP handshake.
*
* @param url URL of the remote MCP server (e.g. "http://localhost:3001/mcp")
* @param options Seams for timeout, transport factory, env, and logger.
* @returns A resolved McpBridge — ready to call tools.
* @throws Error if the connection or handshake fails (after no retry — connect is not retried).
*/
export async function connectToServer(
url: string,
options?: ConnectOptions,
): Promise<McpBridge>
/**
* Call a named tool on the remote server via `bridge`.
* Applies timeout + exponential backoff retry on transient errors.
*
* @param bridge A connected McpBridge (from connectToServer).
* @param name Tool name to invoke on the remote server.
* @param args Tool arguments — arbitrary JSON-serializable object.
* @param options Timeout, retry, env, and logger overrides.
* @returns The CallToolResult from the remote server (content array + isError flag).
* @throws McpBridgeError (non-retryable) or after max retries exceeded.
*/
export async function callTool(
bridge: McpBridge,
name: string,
args: Record<string, unknown>,
options?: CallToolOptions,
): Promise<CallToolResult>
3. Error types
/**
* Thrown by callTool() when a non-retryable error occurs or max retries are exhausted.
*
* `cause` holds the original error (McpError, TypeError, or DOMException).
* `attempts` is the number of attempts made (1 to maxAttempts).
* `retryable` is false when the error was classified as non-retryable.
*/
export class McpBridgeError extends Error {
readonly cause: unknown;
readonly attempts: number;
readonly retryable: boolean;
}
4. Retry semantics
| Error type | Retryable |
|---|---|
McpError with ErrorCode.InternalError (-32603) |
Yes |
McpError with ErrorCode.RequestTimeout (-32001) |
Yes |
McpError with ErrorCode.ConnectionClosed (-32000) |
Yes |
McpError with ErrorCode.InvalidRequest (-32600) |
No |
McpError with ErrorCode.MethodNotFound (-32601) |
No |
McpError with ErrorCode.InvalidParams (-32602) |
No |
DOMException with name === 'AbortError' (timeout) |
Yes |
TypeError (network failure) |
Yes |
Backoff formula: delayMs = 1000 * 2^(attempt - 1) — 1s after first failure, 2s after second.
Max attempts: 3 (configurable via CallToolOptions.maxAttempts).
The 3-attempt default means at most 2 retries (attempts 1, 2, 3).
Timeout implementation: Use AbortController + AbortSignal. Pass signal to client.callTool() via RequestOptions. The SDK respects the signal and throws AbortError.
No retry on connectToServer. Connection failures surface directly to the caller — the retry budget belongs to callTool. If a connection drops between calls, the caller is responsible for reconnecting.
5. Environment variable
| Variable | Type | Default | Semantics |
|---|---|---|---|
COLIBRI_MCP_TIMEOUT |
positive integer (ms) | 30000 |
Timeout for both connect handshake and per-tool call |
Must be added to src/config.ts Zod schema (optional with default). Must stay in COLIBRI_* namespace; AMS_* is rejected by the existing assertNoDonorNamespace guard.
6. Logging contract
All log output writes to console.error (stderr only). process.stdout is owned by the inbound StdioServerTransport — any write there corrupts the JSON-RPC frame stream. Callers may inject a custom logger for testing.
Log lines use the prefix [mcp-bridge]:
- Connection attempt:
[mcp-bridge] connecting to <url> - Connect success:
[mcp-bridge] connected to <url> - Tool call attempt N:
[mcp-bridge] callTool <name> attempt <n>/<maxAttempts> - Retry:
[mcp-bridge] retrying in <delay>ms (error: <message>) - Non-retryable failure:
[mcp-bridge] callTool <name> non-retryable error: <message> - Max retries exceeded:
[mcp-bridge] callTool <name> failed after <n> attempt(s)
7. No migration
The bridge is purely in-memory. Active McpBridge instances hold a Client + Transport pair — no database persistence required. No migration file is created for this task.
Justification: Bridge connections are ephemeral caller-managed resources (analogous to an HTTP client). Persisting them would require session affinity or reconnect logic that is out of scope for Phase 0.
8. Library-only scope
mcp-bridge.ts is a library module. It:
- Does NOT import from
src/server.tsorsrc/tools/. - Does NOT register any MCP tool in
src/server.ts. - Does NOT call
notify()fromsrc/domains/integrations/notifications.ts. - Exposes a pure functional API (
connectToServer+callTool) that other domains (δ Model Router, etc.) can consume in a later phase.
9. Acceptance criteria → test mapping
| Criterion | Test description |
|---|---|
McpBridge wraps outbound MCP client |
Unit: connectToServer returns object with .client and .url properties |
connectToServer(url) creates client + returns bridge |
Unit: assert bridge.url equals input; bridge.client is instance of Client |
callTool(bridge, name, args) calls remote + returns result |
Integration: InMemoryTransport pair; mock server registers a tool; callTool returns correct result |
Timeout default 30s via COLIBRI_MCP_TIMEOUT |
Unit: pass env = { NODE_ENV: 'test', COLIBRI_MCP_TIMEOUT: '500' }; verify timeout is used |
| Retry 3 attempts exponential backoff | Unit: mock transport that fails 2× then succeeds; assert 3 total attempts and ~3s backoff calls |
| Non-retryable error does not retry | Unit: mock transport that throws InvalidRequest; assert exactly 1 attempt; McpBridgeError.retryable = false |
| Max retries exceeded → McpBridgeError | Unit: mock that always fails with transient error; assert McpBridgeError thrown after 3 attempts |
close() disconnects cleanly |
Unit: bridge.close() resolves; subsequent callTool throws |
10. Files to create / modify
New files
| Path | Purpose |
|---|---|
src/domains/integrations/mcp-bridge.ts |
Implementation |
src/__tests__/domains/integrations/mcp-bridge.test.ts |
Tests |
docs/audits/p0-9-1-nu-mcp-bridge-audit.md |
Step 1 (done) |
docs/contracts/p0-9-1-nu-mcp-bridge-contract.md |
This file (Step 2) |
docs/packets/p0-9-1-nu-mcp-bridge-packet.md |
Step 3 |
docs/verification/p0-9-1-nu-mcp-bridge-verification.md |
Step 5 |
Modified files
| Path | Change |
|---|---|
src/domains/integrations/index.ts |
Add export * from './mcp-bridge.js' |
src/config.ts |
Add COLIBRI_MCP_TIMEOUT: z.coerce.number().int().positive().default(30000) |