P0.9.1 — ν MCP Bridge — Packet
Step 3 of the 5-step chain. Execution plan — exact file shapes, change diffs, and implementation order. This packet gates Step 4 (implementation).
Overview
Four files to create or modify:
src/config.ts— addCOLIBRI_MCP_TIMEOUTto Zod schemasrc/domains/integrations/mcp-bridge.ts— the bridge librarysrc/domains/integrations/index.ts— add barrel exportsrc/__tests__/domains/integrations/mcp-bridge.test.ts— tests
One doc file to create:
docs/verification/p0-9-1-nu-mcp-bridge-verification.md— Step 5
1. src/config.ts — diff
Add one line to the Zod schema block (after the existing COLIBRI_STARTUP_TIMEOUT_MS entry):
+ /**
+ * Timeout for outbound MCP bridge connections and tool calls (P0.9.1).
+ *
+ * Applied to both the initial `connect()` handshake and to each
+ * `callTool()` attempt. Configures `AbortController` signal duration.
+ * Defaults to 30000 ms (30 s) per spec.
+ *
+ * Namespace: COLIBRI_* only. AMS_* is rejected by assertNoDonorNamespace.
+ */
+ COLIBRI_MCP_TIMEOUT: z.coerce
+ .number({
+ invalid_type_error: 'COLIBRI_MCP_TIMEOUT must be a positive integer',
+ })
+ .int('COLIBRI_MCP_TIMEOUT must be an integer')
+ .positive('COLIBRI_MCP_TIMEOUT must be > 0')
+ .default(30000),
2. src/domains/integrations/mcp-bridge.ts — full file plan
Module structure
// JSDoc file header (task + canonical refs)
// Imports: Client from SDK, StreamableHTTPClientTransport, ErrorCode, McpError, CallToolResultSchema
// Imports: z from 'zod'
// ─────────────────────────────────────────────
// Section: Exported types
// McpBridge interface
// TransportFactory type
// ConnectOptions interface
// CallToolOptions interface
// ─────────────────────────────────────────────
// Section: Error class
// McpBridgeError extends Error
// ─────────────────────────────────────────────
// Section: Internal helpers
// resolveTimeoutMs(env, override): number
// isRetryable(err): boolean
// sleep(ms): Promise<void>
// callWithTimeout<T>(fn, timeoutMs): Promise<T>
// ─────────────────────────────────────────────
// Section: Exported constants
// MCP_TIMEOUT_ENV_KEY = 'COLIBRI_MCP_TIMEOUT'
// DEFAULT_TIMEOUT_MS = 30_000
// DEFAULT_MAX_ATTEMPTS = 3
// BACKOFF_BASE_MS = 1_000
// ─────────────────────────────────────────────
// Section: Public API
// connectToServer(url, options?): Promise<McpBridge>
// callTool(bridge, name, args, options?): Promise<CallToolResult>
Key implementation decisions
connectToServer — transport factory injection:
const factory = options?.transportFactory ?? defaultTransportFactory;
const transport = factory(url);
const client = new Client({ name: 'colibri-mcp-bridge', version: '0.0.1' });
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
await client.connect(transport, { signal: controller.signal });
} finally {
clearTimeout(timer);
}
return { client, url, close: () => client.close() };
defaultTransportFactory:
function defaultTransportFactory(url: string): Transport {
return new StreamableHTTPClientTransport(new URL(url));
}
callTool — retry loop with backoff:
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const result = await bridge.client.callTool(
{ name, arguments: args },
CallToolResultSchema,
{ signal: controller.signal },
);
return result;
} finally {
clearTimeout(timer);
}
} catch (err) {
if (!isRetryable(err) || attempt === maxAttempts) {
throw new McpBridgeError(
`callTool "${name}" failed after ${attempt} attempt(s): ${errMsg(err)}`,
{ cause: err, attempts: attempt, retryable: isRetryable(err) },
);
}
const delay = BACKOFF_BASE_MS * Math.pow(2, attempt - 1);
logger(`[mcp-bridge] retrying in ${delay}ms (error: ${errMsg(err)})`);
await sleep(delay);
}
}
isRetryable:
function isRetryable(err: unknown): boolean {
if (err instanceof McpError) {
return (
err.code === ErrorCode.InternalError ||
err.code === ErrorCode.RequestTimeout ||
err.code === ErrorCode.ConnectionClosed
);
}
if (err instanceof DOMException && err.name === 'AbortError') return true;
if (err instanceof TypeError) return true; // network failure
return false;
}
3. src/domains/integrations/index.ts — diff
export * from './notifications.js';
+export * from './mcp-bridge.js';
4. src/__tests__/domains/integrations/mcp-bridge.test.ts — test plan
Test transport helper
/**
* Creates a linked Client+Server InMemoryTransport pair for unit testing.
* The server has a single tool: `echo` that returns its args as content.
*/
async function createInMemoryBridge(): Promise<{
bridge: McpBridge;
serverTransport: InMemoryTransport;
}>
Test cases (8 groups)
connectToServerreturns a valid bridge- Assert
bridge.url === url - Assert
bridge.client instanceof Client - Assert
bridge.closeis a function
- Assert
callToolroundtrip via InMemory server- Mock server with
echotool (returns{ text: JSON.stringify(args) }) - Call
callTool(bridge, 'echo', { hello: 'world' }) - Assert result.content contains expected text
- Mock server with
- Timeout via
COLIBRI_MCP_TIMEOUTenv- Pass
env = { NODE_ENV: 'test', COLIBRI_MCP_TIMEOUT: '100' } - Mock transport that hangs (never resolves)
- Assert
McpBridgeErrorthrown withcausethat is AbortError/timeout
- Pass
- Retry on transient error — success on 3rd attempt
- Mock transport that throws
McpError(ErrorCode.InternalError)twice, succeeds on 3rd - Assert result is returned; assert
attemptsis 3 (via spy on logger) - Use fake sleep (override sleep via Jest fake timers or inject)
- Mock transport that throws
- Non-retryable error — no retry
- Mock transport throws
McpError(ErrorCode.MethodNotFound) - Assert
McpBridgeErrorthrown after exactly 1 attempt - Assert
error.retryable === false - Assert
error.attempts === 1
- Mock transport throws
- Max retries exceeded → McpBridgeError
- Mock transport always throws
McpError(ErrorCode.InternalError) - Assert
McpBridgeErrorthrown;error.attempts === 3
- Mock transport always throws
close()resolves cleanly- Call
bridge.close() - Await resolves without error
- Call
- Default timeout is 30000 ms
- Call
resolveTimeoutMs(process.env, undefined)with no env override - Assert result === 30000
- Call
Test infrastructure
- Use
jest.useFakeTimers()to avoid real sleep delays in retry tests. - Override
sleepvia a module-level injectable or usejest.spyOn+jest.runAllTimersAsync(). - The InMemory mock server uses
Serverfrom@modelcontextprotocol/sdk/server/index.jswithsetRequestHandler(CallToolRequestSchema, ...).
5. Verification doc structure (Step 5)
After tests pass, record:
npm run buildoutput (zero errors)npm test -- --testPathPattern mcp-bridgeoutput (all tests pass)- Full suite pass count
- Any pre-existing flakes noted
Migration decision
No migration. Bridge state is ephemeral. The contract §7 justification stands.
Implementation order
- Edit
src/config.ts— addCOLIBRI_MCP_TIMEOUT - Create
src/domains/integrations/mcp-bridge.ts - Edit
src/domains/integrations/index.ts— add barrel export - Create
src/__tests__/domains/integrations/mcp-bridge.test.ts - Run
npm run build && npm test - Write
docs/verification/p0-9-1-nu-mcp-bridge-verification.md
All changes land in a single feat(p0-9-1) commit (Step 4).