From e3b08087261fe05cd9e07d6544974bd38858b6d8 Mon Sep 17 00:00:00 2001 From: Daniel Wise Date: Wed, 4 Mar 2026 20:47:45 -0800 Subject: [PATCH] feat(mcp): add policy-gated MCP bridge with audit pipeline Implement discover/connect/execute bridge contracts, runtime wiring, default adapter, tests, and rollout docs. Archive full-mcp-tool-bridge and sync delta specs into openspec/specs. --- README.md | 4 +- docs/mcp-bridge-rollout.md | 94 +++ .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/mcp-policy-and-audit/spec.md | 0 .../specs/mcp-server-connection/spec.md | 0 .../specs/mcp-server-discovery/spec.md | 0 .../specs/mcp-tool-execution-pipeline/spec.md | 0 .../2026-03-04-full-mcp-tool-bridge/tasks.md | 34 + .../changes/full-mcp-tool-bridge/tasks.md | 34 - openspec/specs/mcp-policy-and-audit/spec.md | 26 + openspec/specs/mcp-server-connection/spec.md | 19 + openspec/specs/mcp-server-discovery/spec.md | 19 + .../specs/mcp-tool-execution-pipeline/spec.md | 19 + src/cli/runtime.ts | 20 +- src/config/mcp-loader.ts | 28 + src/mcp/bridge.ts | 686 ++++++++++++++++++ src/mcp/default-adapter.ts | 151 ++++ .../mcp-bridge.integration.test.ts | 73 ++ tests/mcp-bridge.test.ts | 282 +++++++ 21 files changed, 1453 insertions(+), 36 deletions(-) create mode 100644 docs/mcp-bridge-rollout.md rename openspec/changes/{full-mcp-tool-bridge => archive/2026-03-04-full-mcp-tool-bridge}/.openspec.yaml (100%) rename openspec/changes/{full-mcp-tool-bridge => archive/2026-03-04-full-mcp-tool-bridge}/design.md (100%) rename openspec/changes/{full-mcp-tool-bridge => archive/2026-03-04-full-mcp-tool-bridge}/proposal.md (100%) rename openspec/changes/{full-mcp-tool-bridge => archive/2026-03-04-full-mcp-tool-bridge}/specs/mcp-policy-and-audit/spec.md (100%) rename openspec/changes/{full-mcp-tool-bridge => archive/2026-03-04-full-mcp-tool-bridge}/specs/mcp-server-connection/spec.md (100%) rename openspec/changes/{full-mcp-tool-bridge => archive/2026-03-04-full-mcp-tool-bridge}/specs/mcp-server-discovery/spec.md (100%) rename openspec/changes/{full-mcp-tool-bridge => archive/2026-03-04-full-mcp-tool-bridge}/specs/mcp-tool-execution-pipeline/spec.md (100%) create mode 100644 openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/tasks.md delete mode 100644 openspec/changes/full-mcp-tool-bridge/tasks.md create mode 100644 openspec/specs/mcp-policy-and-audit/spec.md create mode 100644 openspec/specs/mcp-server-connection/spec.md create mode 100644 openspec/specs/mcp-server-discovery/spec.md create mode 100644 openspec/specs/mcp-tool-execution-pipeline/spec.md create mode 100644 src/config/mcp-loader.ts create mode 100644 src/mcp/bridge.ts create mode 100644 src/mcp/default-adapter.ts create mode 100644 tests/integration/mcp-bridge.integration.test.ts create mode 100644 tests/mcp-bridge.test.ts diff --git a/README.md b/README.md index e0c0a62..479eeb8 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ pnpm build - `src/db` - PGLite client and migrations - `src/automation` - scheduler, hooks, automation runner - `src/observability` - traces/transcripts and optional OTel -- `src/mcp` - MCP process client +- `src/mcp` - MCP process client and policy-gated bridge - `tests` - unit/integration-facing tests for core behavior ## Configuration @@ -90,6 +90,7 @@ Environment variables (BYOK): - `DUBSBOT_ANTHROPIC_MODEL` - `DUBSBOT_GOOGLE_MODEL` (defaults to `gemini-3.1-pro-preview`) - `DUBSBOT_OTEL_ENABLED=1` to enable telemetry export hooks +- `DUBSBOT_MCP_SERVERS_JSON` to configure MCP servers for bridge discovery/connect/execute - `DUBSBOT_EMBEDDING_STRATEGY_V2=1` to enable explicit embedding strategy resolution/fallback - `DUBSBOT_EMBEDDING_STRATEGY_CONFIG_JSON` to provide explicit strategy config - `DUBSBOT_EMBEDDING_PROVENANCE_LOG=1` to emit embedding provenance log lines @@ -100,3 +101,4 @@ Environment variables (BYOK): - This project intentionally uses Biome only (no ESLint/Prettier). - Retrieval proofing benchmark schema/workflow docs: `docs/retrieval-proofing-benchmark-schema.md` and `docs/retrieval-proofing.md`. - Embedding strategy rollout guide: `docs/embedding-strategy-rollout.md`. +- MCP bridge contracts/rollout guide: `docs/mcp-bridge-rollout.md`. diff --git a/docs/mcp-bridge-rollout.md b/docs/mcp-bridge-rollout.md new file mode 100644 index 0000000..cbbbd5c --- /dev/null +++ b/docs/mcp-bridge-rollout.md @@ -0,0 +1,94 @@ +# MCP Bridge Contracts And Rollout Guide + +This guide documents the MCP bridge contracts introduced for `discover`, `connect`, and `execute`, plus a safe rollout and rollback procedure. + +## Bridge Contract + +### Discover + +- Operation: `discover()` +- Response shape: + - `correlationId`: shared identifier for traceability + - `servers[]`: normalized records sorted by `serverId` + - `serverId` + - `displayName` + - `transport` (`stdio` | `http` | `sse` | `unknown`) + - `availability` (`available` | `unavailable`) + - optional `diagnostics` for unavailable servers + +### Connect + +- Operation: `connect(serverId, correlationId?)` +- Success: + - `ok: true` + - `state: connected` + - `sessionId` + - `reusedSession` (health-validated reuse only) +- Failure: + - `ok: false` + - `state: failed` + - standardized error category (`validation`, `connection_failed`, `timeout`, `internal`, etc.) + +### Execute + +- Operation: `execute({ serverId, toolName, input, ... })` +- Behavior: + - validates input + - evaluates policy before invocation + - denies with `policy_denied` without invoking provider when blocked + - returns normalized execution envelope for both success/failure +- Envelope fields: + - `ok` + - `correlationId` + - `serverId`, `toolName` + - `policyDecision` + - `error` (or `null`) + - `timing` (`startedAt`, `endedAt`, `durationMs`) + - `outputSummary` (bounded/redacted) + +## Audit Event Schema + +Each bridge operation emits append-only audit records: + +- `operation`: `discover` | `connect` | `execute` +- `outcome`: `success` | `failure` | `denied` +- `correlationId` +- optional `serverId`, `toolName` +- optional policy snapshot (`allowed`, `requiresApproval`, `decision`, `reason`, `sideEffect`) +- optional standardized `error` +- `timing`: start/end/duration +- operation metadata with redacted summaries of request/response payloads where relevant + +## MCP Server Configuration + +Set `DUBSBOT_MCP_SERVERS_JSON` to a JSON array: + +```json +[ + { + "id": "local-tools", + "displayName": "Local Tools", + "transport": "stdio", + "command": "node", + "args": ["./scripts/mcp-server.js"], + "cwd": "/workspace/project" + } +] +``` + +Invalid configurations are skipped from active use and surfaced as `unavailable` discovery records with `misconfigured` diagnostics. + +## Incremental Rollout + +1. Configure one known-safe MCP server in `DUBSBOT_MCP_SERVERS_JSON`. +2. Run discovery and verify normalized server/diagnostic output. +3. Run connect/execute against low-risk tools and inspect `mcp.bridge.*` trace entries. +4. Expand server list gradually after confirming policy-denied flows and failure classification behavior in staging. +5. Promote to production once success, denied, and failure audit records are all observed. + +## Rollback + +1. Remove or empty `DUBSBOT_MCP_SERVERS_JSON`. +2. Restart the process so bridge discovery returns an empty server set. +3. Verify no new `mcp.bridge.execute` success events are emitted. +4. Restore config only after corrective fixes and staging verification. diff --git a/openspec/changes/full-mcp-tool-bridge/.openspec.yaml b/openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/.openspec.yaml similarity index 100% rename from openspec/changes/full-mcp-tool-bridge/.openspec.yaml rename to openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/.openspec.yaml diff --git a/openspec/changes/full-mcp-tool-bridge/design.md b/openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/design.md similarity index 100% rename from openspec/changes/full-mcp-tool-bridge/design.md rename to openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/design.md diff --git a/openspec/changes/full-mcp-tool-bridge/proposal.md b/openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/proposal.md similarity index 100% rename from openspec/changes/full-mcp-tool-bridge/proposal.md rename to openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/proposal.md diff --git a/openspec/changes/full-mcp-tool-bridge/specs/mcp-policy-and-audit/spec.md b/openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/specs/mcp-policy-and-audit/spec.md similarity index 100% rename from openspec/changes/full-mcp-tool-bridge/specs/mcp-policy-and-audit/spec.md rename to openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/specs/mcp-policy-and-audit/spec.md diff --git a/openspec/changes/full-mcp-tool-bridge/specs/mcp-server-connection/spec.md b/openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/specs/mcp-server-connection/spec.md similarity index 100% rename from openspec/changes/full-mcp-tool-bridge/specs/mcp-server-connection/spec.md rename to openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/specs/mcp-server-connection/spec.md diff --git a/openspec/changes/full-mcp-tool-bridge/specs/mcp-server-discovery/spec.md b/openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/specs/mcp-server-discovery/spec.md similarity index 100% rename from openspec/changes/full-mcp-tool-bridge/specs/mcp-server-discovery/spec.md rename to openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/specs/mcp-server-discovery/spec.md diff --git a/openspec/changes/full-mcp-tool-bridge/specs/mcp-tool-execution-pipeline/spec.md b/openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/specs/mcp-tool-execution-pipeline/spec.md similarity index 100% rename from openspec/changes/full-mcp-tool-bridge/specs/mcp-tool-execution-pipeline/spec.md rename to openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/specs/mcp-tool-execution-pipeline/spec.md diff --git a/openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/tasks.md b/openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/tasks.md new file mode 100644 index 0000000..7c6cc00 --- /dev/null +++ b/openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/tasks.md @@ -0,0 +1,34 @@ +## 1. Bridge Foundations + +- [x] 1.1 Define `McpBridge` interfaces and shared response/error envelope types for `discover`, `connect`, and `execute` +- [x] 1.2 Add bridge module wiring to existing MCP client, policy service, and logging dependencies +- [x] 1.3 Implement correlation-id generation and propagation helpers used across bridge operations + +## 2. Discovery and Connection + +- [x] 2.1 Implement server discovery adapter that returns normalized server metadata and availability status +- [x] 2.2 Add discovery diagnostics mapping for unreachable/misconfigured servers +- [x] 2.3 Implement connection lifecycle flow with explicit connected/failed states and standardized error categories +- [x] 2.4 Add connection health validation before session reuse + +## 3. Policy-Gated Tool Execution Pipeline + +- [x] 3.1 Implement execution entry point that validates input and builds an execution envelope +- [x] 3.2 Add mandatory policy evaluation step before MCP tool invocation +- [x] 3.3 Implement provider invocation adapter and map provider failures into standardized bridge error categories +- [x] 3.4 Ensure denied policy decisions short-circuit invocation and return `policy_denied` + +## 4. Audit and Observability + +- [x] 4.1 Define audit event schema for discovery/connect/execute attempts and outcomes +- [x] 4.2 Emit append-only audit records with correlation id, decision metadata, and outcome classification +- [x] 4.3 Capture and emit timing metadata (start, end, duration) for execution attempts +- [x] 4.4 Add bounded payload redaction/summarization to avoid large or sensitive audit entries + +## 5. Verification and Rollout + +- [x] 5.1 Add unit tests for discovery normalization, connection failure classification, and execution envelope mapping +- [x] 5.2 Add policy enforcement tests proving denied requests never invoke tools +- [x] 5.3 Add audit emission tests for success, failure, and denied scenarios +- [x] 5.4 Add integration tests for end-to-end discover/connect/execute bridge flow +- [x] 5.5 Document bridge contracts and incremental rollout/rollback procedure diff --git a/openspec/changes/full-mcp-tool-bridge/tasks.md b/openspec/changes/full-mcp-tool-bridge/tasks.md deleted file mode 100644 index 50aa9dc..0000000 --- a/openspec/changes/full-mcp-tool-bridge/tasks.md +++ /dev/null @@ -1,34 +0,0 @@ -## 1. Bridge Foundations - -- [ ] 1.1 Define `McpBridge` interfaces and shared response/error envelope types for `discover`, `connect`, and `execute` -- [ ] 1.2 Add bridge module wiring to existing MCP client, policy service, and logging dependencies -- [ ] 1.3 Implement correlation-id generation and propagation helpers used across bridge operations - -## 2. Discovery and Connection - -- [ ] 2.1 Implement server discovery adapter that returns normalized server metadata and availability status -- [ ] 2.2 Add discovery diagnostics mapping for unreachable/misconfigured servers -- [ ] 2.3 Implement connection lifecycle flow with explicit connected/failed states and standardized error categories -- [ ] 2.4 Add connection health validation before session reuse - -## 3. Policy-Gated Tool Execution Pipeline - -- [ ] 3.1 Implement execution entry point that validates input and builds an execution envelope -- [ ] 3.2 Add mandatory policy evaluation step before MCP tool invocation -- [ ] 3.3 Implement provider invocation adapter and map provider failures into standardized bridge error categories -- [ ] 3.4 Ensure denied policy decisions short-circuit invocation and return `policy_denied` - -## 4. Audit and Observability - -- [ ] 4.1 Define audit event schema for discovery/connect/execute attempts and outcomes -- [ ] 4.2 Emit append-only audit records with correlation id, decision metadata, and outcome classification -- [ ] 4.3 Capture and emit timing metadata (start, end, duration) for execution attempts -- [ ] 4.4 Add bounded payload redaction/summarization to avoid large or sensitive audit entries - -## 5. Verification and Rollout - -- [ ] 5.1 Add unit tests for discovery normalization, connection failure classification, and execution envelope mapping -- [ ] 5.2 Add policy enforcement tests proving denied requests never invoke tools -- [ ] 5.3 Add audit emission tests for success, failure, and denied scenarios -- [ ] 5.4 Add integration tests for end-to-end discover/connect/execute bridge flow -- [ ] 5.5 Document bridge contracts and incremental rollout/rollback procedure diff --git a/openspec/specs/mcp-policy-and-audit/spec.md b/openspec/specs/mcp-policy-and-audit/spec.md new file mode 100644 index 0000000..46f642c --- /dev/null +++ b/openspec/specs/mcp-policy-and-audit/spec.md @@ -0,0 +1,26 @@ +# mcp-policy-and-audit Specification + +## Purpose +TBD - created by archiving change full-mcp-tool-bridge. Update Purpose after archive. +## Requirements +### Requirement: Policy SHALL be enforced before MCP tool invocation +The system SHALL evaluate policy for every tool execution request before invoking the remote tool and SHALL block execution when policy denies access. + +#### Scenario: Policy denies tool execution +- **WHEN** a caller attempts to execute a tool that is not permitted by policy +- **THEN** the system returns a `policy_denied` result and does not invoke the MCP tool + +### Requirement: Bridge SHALL emit auditable execution records +The system SHALL emit append-only audit events for execution attempts, policy decisions, connection outcomes, and terminal tool results using a shared correlation identifier. + +#### Scenario: Audit record is emitted for denied execution +- **WHEN** policy denies a tool execution request +- **THEN** the system writes an audit event containing request metadata, denial reason, and correlation id + +### Requirement: Audit records SHALL include outcome timing metadata +The system SHALL capture start timestamp, end timestamp, and duration for execution attempts to support operational analysis and incident reconstruction. + +#### Scenario: Successful execution includes timing +- **WHEN** a tool execution completes successfully +- **THEN** the emitted audit event includes start time, end time, and calculated duration fields + diff --git a/openspec/specs/mcp-server-connection/spec.md b/openspec/specs/mcp-server-connection/spec.md new file mode 100644 index 0000000..ea662d7 --- /dev/null +++ b/openspec/specs/mcp-server-connection/spec.md @@ -0,0 +1,19 @@ +# mcp-server-connection Specification + +## Purpose +TBD - created by archiving change full-mcp-tool-bridge. Update Purpose after archive. +## Requirements +### Requirement: Bridge SHALL manage MCP connection lifecycle +The system SHALL expose a connect operation that validates the requested server, attempts session establishment, and returns an explicit connected or failed result with standardized error classification. + +#### Scenario: Successful server connection +- **WHEN** a caller requests connection to a valid and reachable server identifier +- **THEN** the system returns a connected state with a session reference and connection metadata + +### Requirement: Connection failures SHALL be explicit and non-ambiguous +The system SHALL classify connection failures into standardized categories and SHALL include actionable failure context without leaking sensitive transport details. + +#### Scenario: Connection fails due to timeout +- **WHEN** server connection cannot complete within configured timeout limits +- **THEN** the system returns a `connection_failed` classification with timeout context and no session reference + diff --git a/openspec/specs/mcp-server-discovery/spec.md b/openspec/specs/mcp-server-discovery/spec.md new file mode 100644 index 0000000..b0ce51f --- /dev/null +++ b/openspec/specs/mcp-server-discovery/spec.md @@ -0,0 +1,19 @@ +# mcp-server-discovery Specification + +## Purpose +TBD - created by archiving change full-mcp-tool-bridge. Update Purpose after archive. +## Requirements +### Requirement: Bridge SHALL list discoverable MCP servers +The system SHALL provide a discovery operation that returns all configured or reachable MCP servers as normalized records including server identifier, display name, transport type, and availability status. + +#### Scenario: Discovery succeeds with configured servers +- **WHEN** a caller invokes bridge discovery with valid runtime configuration +- **THEN** the system returns a deterministic list of normalized server records ordered by stable server identifier + +### Requirement: Discovery SHALL report diagnostics for unavailable servers +The system SHALL include diagnostic details for servers that cannot be reached or validated so callers can distinguish misconfiguration from temporary connectivity issues. + +#### Scenario: One server is unavailable during discovery +- **WHEN** discovery encounters a server that fails validation or handshake +- **THEN** the returned record includes an unavailable status and machine-readable diagnostic metadata + diff --git a/openspec/specs/mcp-tool-execution-pipeline/spec.md b/openspec/specs/mcp-tool-execution-pipeline/spec.md new file mode 100644 index 0000000..b881e1c --- /dev/null +++ b/openspec/specs/mcp-tool-execution-pipeline/spec.md @@ -0,0 +1,19 @@ +# mcp-tool-execution-pipeline Specification + +## Purpose +TBD - created by archiving change full-mcp-tool-bridge. Update Purpose after archive. +## Requirements +### Requirement: Bridge SHALL execute tools through a single pipeline +The system SHALL provide one execution entry point that accepts server id, tool name, and validated input payload, and returns a normalized execution envelope for both success and failure outcomes. + +#### Scenario: Tool execution succeeds +- **WHEN** a caller executes a valid tool on a connected and authorized server +- **THEN** the system returns a success envelope containing correlation id, timing metadata, and structured tool output summary + +### Requirement: Pipeline SHALL standardize execution errors +The system SHALL map invocation failures to stable error categories so callers can reliably handle retryable and non-retryable outcomes. + +#### Scenario: Tool invocation fails in provider layer +- **WHEN** the MCP provider returns an execution failure +- **THEN** the system returns a `tool_failed` envelope with stable error fields and preserved correlation id + diff --git a/src/cli/runtime.ts b/src/cli/runtime.ts index 7b45964..2dcc3f9 100644 --- a/src/cli/runtime.ts +++ b/src/cli/runtime.ts @@ -1,8 +1,11 @@ import { AgentOrchestrator } from '../agent/orchestrator'; import { loadAgentsConfig } from '../config/agents-loader'; +import { loadMcpServerConfig } from '../config/mcp-loader'; import { loadEmbeddingStrategyConfig } from '../context/embedding/config'; import { createDb } from '../db/client'; import { runMigrations } from '../db/migrate'; +import { McpBridgeService } from '../mcp/bridge'; +import { DefaultMcpServerAdapter } from '../mcp/default-adapter'; import { OptionalOtelExporter } from '../observability/otel'; import { TraceStore } from '../observability/traces'; import { TranscriptStore } from '../observability/transcripts'; @@ -18,6 +21,20 @@ export async function createRuntime() { const agentsConfig = await loadAgentsConfig(process.cwd()); const provider = createProviderAdapter(detectProvider()); const policyEngine = new DefaultPolicyEngine(createDefaultApprovalPolicy()); + const traces = new TraceStore(); + const mcpBridge = new McpBridgeService({ + adapter: new DefaultMcpServerAdapter(loadMcpServerConfig()), + policyEngine, + auditSink: { + append: async (event) => { + await traces.write({ + timestamp: event.timing.endedAt, + type: `mcp.bridge.${event.operation}`, + payload: event as unknown as Record, + }); + }, + }, + }); const orchestrator = new AgentOrchestrator({ provider, policyEngine, @@ -29,8 +46,9 @@ export async function createRuntime() { embeddingStrategyConfig, provider, policyEngine, + mcpBridge, orchestrator, - traces: new TraceStore(), + traces, transcripts: new TranscriptStore(), otel: new OptionalOtelExporter(), tools: new ToolRegistry({ diff --git a/src/config/mcp-loader.ts b/src/config/mcp-loader.ts new file mode 100644 index 0000000..e7a6967 --- /dev/null +++ b/src/config/mcp-loader.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; +import type { McpServerConfig } from '../mcp/default-adapter'; + +const McpServerConfigSchema = z.object({ + id: z.string().min(1), + displayName: z.string().optional(), + transport: z.enum(['stdio', 'http', 'sse', 'unknown']), + command: z.string().optional(), + args: z.array(z.string()).optional(), + cwd: z.string().optional(), + url: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + +const McpServerConfigListSchema = z.array(McpServerConfigSchema); + +export function loadMcpServerConfig(raw = process.env.DUBSBOT_MCP_SERVERS_JSON): McpServerConfig[] { + if (!raw?.trim()) { + return []; + } + + try { + const parsed = JSON.parse(raw) as unknown; + return McpServerConfigListSchema.parse(parsed); + } catch { + return []; + } +} diff --git a/src/mcp/bridge.ts b/src/mcp/bridge.ts new file mode 100644 index 0000000..1d51a96 --- /dev/null +++ b/src/mcp/bridge.ts @@ -0,0 +1,686 @@ +import { randomUUID } from 'node:crypto'; +import type { DefaultPolicyEngine } from '../policy/engine'; +import type { ApprovalDecision } from '../policy/schemas'; +import type { ToolSideEffect } from '../tools/schemas'; + +export type McpBridgeErrorCategory = + | 'validation' + | 'policy_denied' + | 'connection_failed' + | 'tool_failed' + | 'timeout' + | 'internal'; + +export type McpServerAvailability = 'available' | 'unavailable'; +export type McpServerTransport = 'stdio' | 'http' | 'sse' | 'unknown'; +export type McpConnectionState = 'connected' | 'failed'; +export type McpAuditOperation = 'discover' | 'connect' | 'execute'; +export type McpAuditOutcome = 'success' | 'failure' | 'denied'; +export type McpDiscoveryDiagnosticCode = 'unreachable' | 'misconfigured' | 'timeout' | 'unknown'; + +export type McpBridgeError = { + category: McpBridgeErrorCategory; + message: string; + retryable: boolean; + code?: string; +}; + +export type McpDiscoveryDiagnostic = { + code: McpDiscoveryDiagnosticCode; + message: string; + retryable: boolean; +}; + +export type McpServerRecord = { + serverId: string; + displayName: string; + transport: McpServerTransport; + availability: McpServerAvailability; + diagnostics?: McpDiscoveryDiagnostic; + metadata?: Record; +}; + +export type McpConnectResult = + | { + ok: true; + state: 'connected'; + correlationId: string; + serverId: string; + sessionId: string; + connectedAt: string; + reusedSession: boolean; + metadata?: Record; + } + | { + ok: false; + state: 'failed'; + correlationId: string; + serverId: string; + error: McpBridgeError; + }; + +export type McpExecuteRequest = { + serverId: string; + toolName: string; + input: Record; + sideEffect?: ToolSideEffect; + mode?: 'interactive' | 'automation'; + approvalGranted?: boolean; + timeoutMs?: number; + correlationId?: string; +}; + +export type McpExecutionEnvelope = { + ok: boolean; + correlationId: string; + serverId: string; + toolName: string; + outputSummary: string; + output?: unknown; + policyDecision: ApprovalDecision | null; + error: McpBridgeError | null; + timing: { + startedAt: string; + endedAt: string; + durationMs: number; + }; +}; + +export type McpAuditEvent = { + operation: McpAuditOperation; + outcome: McpAuditOutcome; + correlationId: string; + serverId?: string; + toolName?: string; + policyDecision?: { + allowed: boolean; + requiresApproval: boolean; + reason: string; + decision: ApprovalDecision['decision']; + sideEffect: ToolSideEffect; + }; + error?: McpBridgeError; + metadata?: Record; + timing: { + startedAt: string; + endedAt: string; + durationMs: number; + }; +}; + +export type McpBridgeSession = { + id: string; + serverId: string; + metadata?: Record; +}; + +export type McpDiscoveryResult = { + correlationId: string; + servers: McpServerRecord[]; +}; + +export type McpInvokeResult = { + output: unknown; + metadata?: Record; +}; + +export interface McpServerAdapter { + discover(): Promise; + connect(serverId: string): Promise; + isSessionHealthy(session: McpBridgeSession): Promise; + invoke( + session: McpBridgeSession, + toolName: string, + input: Record, + timeoutMs?: number + ): Promise; +} + +export interface McpAuditSink { + append(event: McpAuditEvent): Promise | void; +} + +export interface McpBridge { + discover(correlationId?: string): Promise; + connect(serverId: string, correlationId?: string): Promise; + execute(request: McpExecuteRequest): Promise; +} + +type BridgeDeps = { + adapter: McpServerAdapter; + policyEngine: DefaultPolicyEngine; + auditSink?: McpAuditSink; + now?: () => Date; + correlationIdFactory?: () => string; +}; + +const SENSITIVE_KEY_PATTERN = /(token|secret|password|authorization|api[-_]?key|cookie)/i; +const MAX_SUMMARY_LENGTH = 500; + +function createCorrelationId(): string { + return `mcp-${randomUUID()}`; +} + +function classifyError( + error: unknown, + fallback: McpBridgeErrorCategory = 'internal' +): McpBridgeError { + if (error instanceof Error) { + const lowerMessage = error.message.toLowerCase(); + if (lowerMessage.includes('timeout')) { + return { + category: 'timeout', + message: error.message, + retryable: true, + code: 'timeout', + }; + } + if (lowerMessage.includes('policy')) { + return { + category: 'policy_denied', + message: error.message, + retryable: false, + code: 'policy_denied', + }; + } + if (lowerMessage.includes('connect') || lowerMessage.includes('session')) { + return { + category: 'connection_failed', + message: error.message, + retryable: true, + code: 'connection_failed', + }; + } + if (lowerMessage.includes('tool') || lowerMessage.includes('invoke')) { + return { + category: 'tool_failed', + message: error.message, + retryable: true, + code: 'tool_failed', + }; + } + return { + category: fallback, + message: error.message, + retryable: + fallback === 'timeout' || fallback === 'connection_failed' || fallback === 'tool_failed', + code: fallback, + }; + } + + return { + category: fallback, + message: String(error), + retryable: + fallback === 'timeout' || fallback === 'connection_failed' || fallback === 'tool_failed', + code: fallback, + }; +} + +function redactValue(value: unknown, depth = 0): unknown { + if (depth > 3) { + return '[redacted:depth-limit]'; + } + if (typeof value === 'string') { + return value.length > 256 ? `${value.slice(0, 256)}...[truncated]` : value; + } + if (Array.isArray(value)) { + return value.slice(0, 20).map((entry) => redactValue(entry, depth + 1)); + } + if (value && typeof value === 'object') { + const output: Record = {}; + let count = 0; + for (const [key, entry] of Object.entries(value)) { + if (count >= 20) { + output.__truncated__ = true; + break; + } + if (SENSITIVE_KEY_PATTERN.test(key)) { + output[key] = '[redacted:sensitive]'; + } else { + output[key] = redactValue(entry, depth + 1); + } + count += 1; + } + return output; + } + return value; +} + +function summarizePayload(value: unknown): string { + let preview: string; + try { + preview = JSON.stringify(redactValue(value)); + } catch { + preview = '[unserializable]'; + } + if (preview.length > MAX_SUMMARY_LENGTH) { + return `${preview.slice(0, MAX_SUMMARY_LENGTH)}...[truncated]`; + } + return preview; +} + +function durationMs(startedAt: Date, endedAt: Date): number { + return Math.max(0, endedAt.getTime() - startedAt.getTime()); +} + +function toPolicySnapshot(decision: ApprovalDecision): McpAuditEvent['policyDecision'] { + return { + allowed: decision.allowed, + requiresApproval: decision.requiresApproval, + reason: decision.reason, + decision: decision.decision, + sideEffect: decision.sideEffect, + }; +} + +export class McpBridgeService implements McpBridge { + private readonly sessions = new Map(); + private readonly now: () => Date; + private readonly correlationIdFactory: () => string; + + constructor(private readonly deps: BridgeDeps) { + this.now = deps.now ?? (() => new Date()); + this.correlationIdFactory = deps.correlationIdFactory ?? createCorrelationId; + } + + async discover(correlationId = this.correlationIdFactory()): Promise { + const startedAt = this.now(); + let outcome: McpAuditOutcome = 'success'; + let metadata: Record = {}; + let error: McpBridgeError | undefined; + + try { + const servers = await this.deps.adapter.discover(); + const normalized = [...servers].sort((left, right) => + left.serverId.localeCompare(right.serverId) + ); + metadata = { + serverCount: normalized.length, + unavailableCount: normalized.filter((server) => server.availability === 'unavailable') + .length, + }; + return { + correlationId, + servers: normalized, + }; + } catch (caught) { + outcome = 'failure'; + error = classifyError(caught, 'internal'); + throw caught; + } finally { + const endedAt = this.now(); + await this.emitAudit({ + operation: 'discover', + outcome, + correlationId, + metadata: { + ...metadata, + }, + error, + timing: { + startedAt: startedAt.toISOString(), + endedAt: endedAt.toISOString(), + durationMs: durationMs(startedAt, endedAt), + }, + }); + } + } + + async connect( + serverId: string, + correlationId = this.correlationIdFactory() + ): Promise { + const startedAt = this.now(); + if (!serverId.trim()) { + const endedAt = this.now(); + const error: McpBridgeError = { + category: 'validation', + message: 'serverId is required', + retryable: false, + code: 'server_id_required', + }; + await this.emitAudit({ + operation: 'connect', + outcome: 'failure', + correlationId, + serverId, + error, + timing: { + startedAt: startedAt.toISOString(), + endedAt: endedAt.toISOString(), + durationMs: durationMs(startedAt, endedAt), + }, + }); + return { + ok: false, + state: 'failed', + correlationId, + serverId, + error, + }; + } + + const existingSession = this.sessions.get(serverId); + if (existingSession) { + try { + const healthy = await this.deps.adapter.isSessionHealthy(existingSession); + if (healthy) { + const endedAt = this.now(); + await this.emitAudit({ + operation: 'connect', + outcome: 'success', + correlationId, + serverId, + metadata: { + sessionId: existingSession.id, + reusedSession: true, + }, + timing: { + startedAt: startedAt.toISOString(), + endedAt: endedAt.toISOString(), + durationMs: durationMs(startedAt, endedAt), + }, + }); + return { + ok: true, + state: 'connected', + correlationId, + serverId, + sessionId: existingSession.id, + connectedAt: endedAt.toISOString(), + reusedSession: true, + metadata: existingSession.metadata, + }; + } + } catch { + // If health checks fail, force a reconnection attempt. + } + } + + try { + const session = await this.deps.adapter.connect(serverId); + this.sessions.set(serverId, session); + const endedAt = this.now(); + await this.emitAudit({ + operation: 'connect', + outcome: 'success', + correlationId, + serverId, + metadata: { + sessionId: session.id, + reusedSession: false, + }, + timing: { + startedAt: startedAt.toISOString(), + endedAt: endedAt.toISOString(), + durationMs: durationMs(startedAt, endedAt), + }, + }); + return { + ok: true, + state: 'connected', + correlationId, + serverId, + sessionId: session.id, + connectedAt: endedAt.toISOString(), + reusedSession: false, + metadata: session.metadata, + }; + } catch (caught) { + const endedAt = this.now(); + const classified = classifyError(caught, 'connection_failed'); + const error: McpBridgeError = + classified.category === 'timeout' + ? { + category: 'connection_failed', + message: classified.message, + retryable: true, + code: 'timeout', + } + : classified; + await this.emitAudit({ + operation: 'connect', + outcome: 'failure', + correlationId, + serverId, + error, + timing: { + startedAt: startedAt.toISOString(), + endedAt: endedAt.toISOString(), + durationMs: durationMs(startedAt, endedAt), + }, + }); + return { + ok: false, + state: 'failed', + correlationId, + serverId, + error, + }; + } + } + + async execute(request: McpExecuteRequest): Promise { + const correlationId = request.correlationId ?? this.correlationIdFactory(); + const startedAt = this.now(); + const validationError = this.validateExecuteRequest(request); + if (validationError) { + const endedAt = this.now(); + const envelope: McpExecutionEnvelope = { + ok: false, + correlationId, + serverId: request.serverId, + toolName: request.toolName, + outputSummary: '', + policyDecision: null, + error: validationError, + timing: { + startedAt: startedAt.toISOString(), + endedAt: endedAt.toISOString(), + durationMs: durationMs(startedAt, endedAt), + }, + }; + await this.emitAudit({ + operation: 'execute', + outcome: 'failure', + correlationId, + serverId: request.serverId, + toolName: request.toolName, + error: validationError, + timing: envelope.timing, + }); + return envelope; + } + + const policyDecision = this.deps.policyEngine.evaluateToolInvocation({ + invocation: { + tool: `mcp:${request.serverId}:${request.toolName}`, + params: request.input, + sideEffect: request.sideEffect ?? 'network', + policyTag: 'mcp', + }, + mode: request.mode ?? 'interactive', + approvalGranted: request.approvalGranted ?? false, + }); + + if (!policyDecision.allowed || (policyDecision.requiresApproval && !request.approvalGranted)) { + const endedAt = this.now(); + const error: McpBridgeError = { + category: 'policy_denied', + message: policyDecision.reason, + retryable: false, + code: 'policy_denied', + }; + const envelope: McpExecutionEnvelope = { + ok: false, + correlationId, + serverId: request.serverId, + toolName: request.toolName, + outputSummary: 'Execution denied by policy', + policyDecision, + error, + timing: { + startedAt: startedAt.toISOString(), + endedAt: endedAt.toISOString(), + durationMs: durationMs(startedAt, endedAt), + }, + }; + await this.emitAudit({ + operation: 'execute', + outcome: 'denied', + correlationId, + serverId: request.serverId, + toolName: request.toolName, + policyDecision: toPolicySnapshot(policyDecision), + error, + metadata: { + input: summarizePayload(request.input), + }, + timing: envelope.timing, + }); + return envelope; + } + + const connectResult = await this.connect(request.serverId, correlationId); + if (!connectResult.ok) { + const endedAt = this.now(); + const envelope: McpExecutionEnvelope = { + ok: false, + correlationId, + serverId: request.serverId, + toolName: request.toolName, + outputSummary: 'Failed to connect to MCP server', + policyDecision, + error: connectResult.error, + timing: { + startedAt: startedAt.toISOString(), + endedAt: endedAt.toISOString(), + durationMs: durationMs(startedAt, endedAt), + }, + }; + await this.emitAudit({ + operation: 'execute', + outcome: 'failure', + correlationId, + serverId: request.serverId, + toolName: request.toolName, + policyDecision: toPolicySnapshot(policyDecision), + error: connectResult.error, + metadata: { + input: summarizePayload(request.input), + }, + timing: envelope.timing, + }); + return envelope; + } + + try { + const session = this.sessions.get(request.serverId); + if (!session) { + throw new Error('Connection session missing after successful connect'); + } + + const invokeResult = await this.deps.adapter.invoke( + session, + request.toolName, + request.input, + request.timeoutMs + ); + const endedAt = this.now(); + const envelope: McpExecutionEnvelope = { + ok: true, + correlationId, + serverId: request.serverId, + toolName: request.toolName, + output: invokeResult.output, + outputSummary: summarizePayload(invokeResult.output), + policyDecision, + error: null, + timing: { + startedAt: startedAt.toISOString(), + endedAt: endedAt.toISOString(), + durationMs: durationMs(startedAt, endedAt), + }, + }; + await this.emitAudit({ + operation: 'execute', + outcome: 'success', + correlationId, + serverId: request.serverId, + toolName: request.toolName, + policyDecision: toPolicySnapshot(policyDecision), + metadata: { + input: summarizePayload(request.input), + output: envelope.outputSummary, + ...invokeResult.metadata, + }, + timing: envelope.timing, + }); + return envelope; + } catch (caught) { + const endedAt = this.now(); + const error = classifyError(caught, 'tool_failed'); + const envelope: McpExecutionEnvelope = { + ok: false, + correlationId, + serverId: request.serverId, + toolName: request.toolName, + outputSummary: '', + policyDecision, + error, + timing: { + startedAt: startedAt.toISOString(), + endedAt: endedAt.toISOString(), + durationMs: durationMs(startedAt, endedAt), + }, + }; + await this.emitAudit({ + operation: 'execute', + outcome: 'failure', + correlationId, + serverId: request.serverId, + toolName: request.toolName, + policyDecision: toPolicySnapshot(policyDecision), + error, + metadata: { + input: summarizePayload(request.input), + }, + timing: envelope.timing, + }); + return envelope; + } + } + + private validateExecuteRequest(input: McpExecuteRequest): McpBridgeError | null { + if (!input.serverId.trim()) { + return { + category: 'validation', + message: 'serverId is required', + retryable: false, + code: 'server_id_required', + }; + } + if (!input.toolName.trim()) { + return { + category: 'validation', + message: 'toolName is required', + retryable: false, + code: 'tool_name_required', + }; + } + if (!input.input || typeof input.input !== 'object' || Array.isArray(input.input)) { + return { + category: 'validation', + message: 'input must be an object', + retryable: false, + code: 'invalid_input', + }; + } + return null; + } + + private async emitAudit(event: McpAuditEvent): Promise { + await this.deps.auditSink?.append(event); + } +} diff --git a/src/mcp/default-adapter.ts b/src/mcp/default-adapter.ts new file mode 100644 index 0000000..4481f9c --- /dev/null +++ b/src/mcp/default-adapter.ts @@ -0,0 +1,151 @@ +import { randomUUID } from 'node:crypto'; +import type { + McpBridgeSession, + McpInvokeResult, + McpServerAdapter, + McpServerRecord, + McpServerTransport, +} from './bridge'; +import { McpClient } from './client'; + +export type McpServerConfig = { + id: string; + displayName?: string; + transport: McpServerTransport; + command?: string; + args?: string[]; + cwd?: string; + url?: string; + metadata?: Record; +}; + +type SessionState = { + session: McpBridgeSession; + client?: McpClient; + healthy: boolean; +}; + +export class DefaultMcpServerAdapter implements McpServerAdapter { + private readonly servers = new Map(); + private readonly sessions = new Map(); + + constructor(configs: McpServerConfig[]) { + for (const config of configs) { + this.servers.set(config.id, config); + } + } + + async discover(): Promise { + const records: McpServerRecord[] = []; + for (const server of this.servers.values()) { + const diagnostics = this.validateConfig(server); + records.push({ + serverId: server.id, + displayName: server.displayName ?? server.id, + transport: server.transport, + availability: diagnostics ? 'unavailable' : 'available', + diagnostics: diagnostics ?? undefined, + metadata: server.metadata, + }); + } + return records; + } + + async connect(serverId: string): Promise { + const server = this.servers.get(serverId); + if (!server) { + throw new Error(`Connection failed: unknown MCP server "${serverId}"`); + } + const diagnostics = this.validateConfig(server); + if (diagnostics) { + throw new Error(`Connection failed: ${diagnostics.message}`); + } + + const session: McpBridgeSession = { + id: `mcp-session-${randomUUID()}`, + serverId, + metadata: { + transport: server.transport, + }, + }; + + if (server.transport === 'stdio') { + const client = new McpClient( + server.command ?? '', + server.args ?? [], + server.cwd ?? process.cwd() + ); + client.start(); + this.sessions.set(session.id, { session, client, healthy: true }); + return session; + } + + this.sessions.set(session.id, { session, healthy: true }); + return session; + } + + async isSessionHealthy(session: McpBridgeSession): Promise { + const state = this.sessions.get(session.id); + return state?.healthy ?? false; + } + + async invoke( + session: McpBridgeSession, + toolName: string, + input: Record + ): Promise { + const state = this.sessions.get(session.id); + if (!state || !state.healthy) { + throw new Error('Connection failed: session is unavailable'); + } + const server = this.servers.get(session.serverId); + if (!server) { + throw new Error('Connection failed: server missing for active session'); + } + + if (server.transport !== 'stdio') { + throw new Error(`Tool invocation failed: unsupported transport "${server.transport}"`); + } + + if (!state.client) { + throw new Error('Connection failed: stdio client not initialized'); + } + + await state.client.request(toolName, input); + return { + output: { + ok: true, + transport: server.transport, + toolName, + }, + }; + } + + private validateConfig(server: McpServerConfig): McpServerRecord['diagnostics'] | null { + if (!server.id.trim()) { + return { + code: 'misconfigured', + message: 'Server id is required', + retryable: false, + }; + } + if (server.transport === 'stdio') { + if (!server.command?.trim()) { + return { + code: 'misconfigured', + message: 'Stdio transport requires a command', + retryable: false, + }; + } + return null; + } + if (!server.url?.trim()) { + return { + code: 'misconfigured', + message: 'HTTP/SSE transport requires a URL', + retryable: false, + }; + } + return null; + } +} diff --git a/tests/integration/mcp-bridge.integration.test.ts b/tests/integration/mcp-bridge.integration.test.ts new file mode 100644 index 0000000..a29a37d --- /dev/null +++ b/tests/integration/mcp-bridge.integration.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { McpBridgeService, type McpServerAdapter } from '../../src/mcp/bridge'; +import { createDefaultApprovalPolicy } from '../../src/policy/defaults'; +import { DefaultPolicyEngine } from '../../src/policy/engine'; + +describe('integration: mcp bridge discover/connect/execute flow', () => { + it('runs end-to-end flow with correlation propagation and normalized envelopes', async () => { + const adapter: McpServerAdapter = { + discover: async () => [ + { + serverId: 'mcp-a', + displayName: 'MCP A', + transport: 'stdio', + availability: 'available', + }, + ], + connect: async (serverId) => ({ + id: `session-${serverId}`, + serverId, + }), + isSessionHealthy: async () => true, + invoke: async (_session, toolName, input) => ({ + output: { + ok: true, + toolName, + echo: input, + }, + }), + }; + + const bridge = new McpBridgeService({ + adapter, + policyEngine: new DefaultPolicyEngine( + createDefaultApprovalPolicy({ + requireApprovalFor: [], + }) + ), + correlationIdFactory: () => 'corr-integration', + }); + + const discovered = await bridge.discover(); + const connected = await bridge.connect('mcp-a', discovered.correlationId); + const executed = await bridge.execute({ + serverId: 'mcp-a', + toolName: 'echo', + input: { msg: 'hello' }, + sideEffect: 'read', + correlationId: discovered.correlationId, + }); + + expect(discovered.servers).toHaveLength(1); + expect(connected).toMatchObject({ + ok: true, + state: 'connected', + correlationId: discovered.correlationId, + serverId: 'mcp-a', + }); + expect(executed).toMatchObject({ + ok: true, + correlationId: discovered.correlationId, + serverId: 'mcp-a', + toolName: 'echo', + error: null, + }); + expect(executed.output).toMatchObject({ + ok: true, + toolName: 'echo', + echo: { + msg: 'hello', + }, + }); + }); +}); diff --git a/tests/mcp-bridge.test.ts b/tests/mcp-bridge.test.ts new file mode 100644 index 0000000..a1b887c --- /dev/null +++ b/tests/mcp-bridge.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it, vi } from 'vitest'; +import { type McpAuditEvent, McpBridgeService, type McpServerAdapter } from '../src/mcp/bridge'; +import { createDefaultApprovalPolicy } from '../src/policy/defaults'; +import { DefaultPolicyEngine } from '../src/policy/engine'; + +function createAdapter(overrides: Partial = {}): McpServerAdapter { + return { + discover: async () => [], + connect: async (serverId) => ({ + id: `session-${serverId}`, + serverId, + }), + isSessionHealthy: async () => true, + invoke: async (_session, toolName, input) => ({ + output: { + toolName, + input, + }, + }), + ...overrides, + }; +} + +describe('McpBridgeService', () => { + it('normalizes discovery response ordering and diagnostics metadata', async () => { + const bridge = new McpBridgeService({ + adapter: createAdapter({ + discover: async () => [ + { + serverId: 'zeta', + displayName: 'Zeta', + transport: 'stdio', + availability: 'unavailable', + diagnostics: { + code: 'unreachable', + message: 'Handshake failed', + retryable: true, + }, + }, + { + serverId: 'alpha', + displayName: 'Alpha', + transport: 'http', + availability: 'available', + }, + ], + }), + policyEngine: new DefaultPolicyEngine(createDefaultApprovalPolicy()), + correlationIdFactory: () => 'corr-discovery', + }); + + const result = await bridge.discover(); + + expect(result.correlationId).toBe('corr-discovery'); + expect(result.servers.map((server) => server.serverId)).toEqual(['alpha', 'zeta']); + expect(result.servers[1]?.diagnostics).toMatchObject({ + code: 'unreachable', + retryable: true, + }); + }); + + it('returns explicit failed connection state with standardized classification', async () => { + const bridge = new McpBridgeService({ + adapter: createAdapter({ + connect: async () => { + throw new Error('connection timeout while opening session'); + }, + }), + policyEngine: new DefaultPolicyEngine(createDefaultApprovalPolicy()), + correlationIdFactory: () => 'corr-connect', + }); + + const result = await bridge.connect('server-a'); + + expect(result.ok).toBe(false); + expect(result).toMatchObject({ + state: 'failed', + correlationId: 'corr-connect', + serverId: 'server-a', + error: { + category: 'connection_failed', + code: 'timeout', + }, + }); + }); + + it('validates execute input and returns a normalized validation envelope', async () => { + const bridge = new McpBridgeService({ + adapter: createAdapter(), + policyEngine: new DefaultPolicyEngine(createDefaultApprovalPolicy()), + correlationIdFactory: () => 'corr-validation', + }); + + const envelope = await bridge.execute({ + serverId: '', + toolName: '', + input: {}, + }); + + expect(envelope.ok).toBe(false); + expect(envelope.correlationId).toBe('corr-validation'); + expect(envelope.error).toMatchObject({ + category: 'validation', + code: 'server_id_required', + }); + }); + + it('short-circuits on denied policy decisions and never invokes the MCP provider', async () => { + const invoke = vi.fn(async () => ({ output: { ok: true } })); + const bridge = new McpBridgeService({ + adapter: createAdapter({ + invoke, + }), + policyEngine: new DefaultPolicyEngine( + createDefaultApprovalPolicy({ + blockedCommandPatterns: ['mcp:server-a:danger-tool'], + }) + ), + correlationIdFactory: () => 'corr-denied', + }); + + const envelope = await bridge.execute({ + serverId: 'server-a', + toolName: 'danger-tool', + input: { x: 1 }, + sideEffect: 'network', + }); + + expect(envelope.ok).toBe(false); + expect(envelope.error).toMatchObject({ + category: 'policy_denied', + }); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('maps provider failures into standardized tool_failed category', async () => { + const bridge = new McpBridgeService({ + adapter: createAdapter({ + invoke: async () => { + throw new Error('tool invoke failed on provider'); + }, + }), + policyEngine: new DefaultPolicyEngine( + createDefaultApprovalPolicy({ + requireApprovalFor: [], + }) + ), + correlationIdFactory: () => 'corr-exec-fail', + }); + + const envelope = await bridge.execute({ + serverId: 'server-a', + toolName: 'safe-tool', + input: { nested: { token: 'secret-value', message: 'ok' } }, + sideEffect: 'read', + }); + + expect(envelope.ok).toBe(false); + expect(envelope.error).toMatchObject({ + category: 'tool_failed', + }); + }); + + it('reuses only healthy sessions and validates health before reuse', async () => { + const isSessionHealthy = vi + .fn>() + .mockResolvedValueOnce(false) + .mockResolvedValue(true); + const connect = vi + .fn>() + .mockResolvedValueOnce({ id: 'session-1', serverId: 'srv' }) + .mockResolvedValueOnce({ id: 'session-2', serverId: 'srv' }); + + const bridge = new McpBridgeService({ + adapter: createAdapter({ + connect, + isSessionHealthy, + }), + policyEngine: new DefaultPolicyEngine(createDefaultApprovalPolicy()), + correlationIdFactory: () => 'corr-health', + }); + + const first = await bridge.connect('srv'); + const second = await bridge.connect('srv'); + const third = await bridge.connect('srv'); + + expect(first).toMatchObject({ ok: true, reusedSession: false, sessionId: 'session-1' }); + expect(second).toMatchObject({ ok: true, reusedSession: false, sessionId: 'session-2' }); + expect(third).toMatchObject({ ok: true, reusedSession: true, sessionId: 'session-2' }); + expect(connect).toHaveBeenCalledTimes(2); + expect(isSessionHealthy).toHaveBeenCalledTimes(2); + }); + + it('emits append-only audit events for success, failure, and denied scenarios with timing', async () => { + const events: McpAuditEvent[] = []; + const deniedBridge = new McpBridgeService({ + adapter: createAdapter(), + policyEngine: new DefaultPolicyEngine( + createDefaultApprovalPolicy({ + blockedCommandPatterns: ['mcp:srv:blocked'], + }) + ), + auditSink: { + append: async (event) => { + events.push(event); + }, + }, + now: () => new Date('2026-01-01T00:00:00.000Z'), + correlationIdFactory: () => 'corr-audit-denied', + }); + await deniedBridge.execute({ + serverId: 'srv', + toolName: 'blocked', + input: { password: 'abc123' }, + sideEffect: 'network', + }); + + const failingBridge = new McpBridgeService({ + adapter: createAdapter({ + connect: async () => { + throw new Error('connect timeout'); + }, + }), + policyEngine: new DefaultPolicyEngine( + createDefaultApprovalPolicy({ + requireApprovalFor: [], + }) + ), + auditSink: { + append: async (event) => { + events.push(event); + }, + }, + now: () => new Date('2026-01-01T00:00:01.000Z'), + correlationIdFactory: () => 'corr-audit-fail', + }); + await failingBridge.connect('srv'); + + const successBridge = new McpBridgeService({ + adapter: createAdapter({ + discover: async () => [ + { + serverId: 'srv', + displayName: 'Server', + transport: 'stdio', + availability: 'available', + }, + ], + }), + policyEngine: new DefaultPolicyEngine( + createDefaultApprovalPolicy({ + requireApprovalFor: [], + }) + ), + auditSink: { + append: async (event) => { + events.push(event); + }, + }, + now: () => new Date('2026-01-01T00:00:02.000Z'), + correlationIdFactory: () => 'corr-audit-success', + }); + await successBridge.discover(); + + expect(events.length).toBeGreaterThanOrEqual(3); + expect( + events.some((event) => event.operation === 'execute' && event.outcome === 'denied') + ).toBe(true); + expect( + events.some((event) => event.operation === 'connect' && event.outcome === 'failure') + ).toBe(true); + expect( + events.some((event) => event.operation === 'discover' && event.outcome === 'success') + ).toBe(true); + for (const event of events) { + expect(typeof event.correlationId).toBe('string'); + expect(typeof event.timing.startedAt).toBe('string'); + expect(typeof event.timing.endedAt).toBe('string'); + expect(typeof event.timing.durationMs).toBe('number'); + } + }); +});