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.ts or src/tools/.
  • Does NOT register any MCP tool in src/server.ts.
  • Does NOT call notify() from src/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)

Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.