feat(mcp): add policy-gated MCP bridge with audit pipeline#10
feat(mcp): add policy-gated MCP bridge with audit pipeline#10dubscode wants to merge 1 commit intofeat/context-graph-enrichmentfrom
Conversation
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.
PR Checks Summary
|
There was a problem hiding this comment.
Pull request overview
Adds a policy-gated MCP “bridge” layer (discover/connect/execute) with normalized envelopes and an audit pipeline, wiring it into the CLI runtime and documenting rollout/specs.
Changes:
- Introduces
McpBridgeServicewith correlation IDs, standardized error classification, policy enforcement, and audit event emission. - Adds a default MCP server adapter + env-driven server config loader (
DUBSBOT_MCP_SERVERS_JSON) and wires bridge auditing intoTraceStore. - Adds unit/integration tests plus rollout documentation and OpenSpec artifacts (including archiving the original change plan).
Reviewed changes
Copilot reviewed 14 out of 21 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/mcp-bridge.test.ts | Unit tests for discovery sorting, connection failure normalization, policy denial short-circuit, session reuse health checks, and audit emission. |
| tests/integration/mcp-bridge.integration.test.ts | End-to-end discover/connect/execute flow verifying correlation propagation and envelope normalization. |
| src/mcp/default-adapter.ts | Default adapter implementation for configured MCP servers (discovery/connect/invoke) backed by McpClient. |
| src/mcp/bridge.ts | Core bridge service/types: normalized contracts, policy gate, session cache, error classification, payload redaction/summary, audit events. |
| src/config/mcp-loader.ts | Loads MCP server configs from DUBSBOT_MCP_SERVERS_JSON using Zod. |
| src/cli/runtime.ts | Wires MCP bridge into runtime and routes audit events into TraceStore (mcp.bridge.*). |
| openspec/specs/mcp-tool-execution-pipeline/spec.md | Adds spec for standardized execution pipeline and error mapping. |
| openspec/specs/mcp-server-discovery/spec.md | Adds spec for deterministic discovery and diagnostics. |
| openspec/specs/mcp-server-connection/spec.md | Adds spec for explicit connect lifecycle and failure classification. |
| openspec/specs/mcp-policy-and-audit/spec.md | Adds spec for policy enforcement and append-only audit events with timing. |
| openspec/changes/full-mcp-tool-bridge/tasks.md | Removes the in-progress tasks checklist (moved to archive). |
| openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/tasks.md | Archived, completed checklist for the MCP bridge change. |
| openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/specs/mcp-tool-execution-pipeline/spec.md | Archived “added requirements” snapshot. |
| openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/specs/mcp-server-discovery/spec.md | Archived “added requirements” snapshot. |
| openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/specs/mcp-server-connection/spec.md | Archived “added requirements” snapshot. |
| openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/specs/mcp-policy-and-audit/spec.md | Archived “added requirements” snapshot. |
| openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/proposal.md | Archived proposal rationale/scope/impact for the change. |
| openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/design.md | Archived design doc (goals, decisions, risks, migration plan). |
| openspec/changes/archive/2026-03-04-full-mcp-tool-bridge/.openspec.yaml | Archived OpenSpec metadata for the change. |
| docs/mcp-bridge-rollout.md | Documents bridge contracts, audit schema, env config, and rollout/rollback steps. |
| README.md | Updates repo overview/config docs to mention MCP bridge and env var + rollout guide. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| this.sessions.set(session.id, { session, healthy: true }); | ||
| return session; | ||
| } | ||
|
|
||
| async isSessionHealthy(session: McpBridgeSession): Promise<boolean> { | ||
| const state = this.sessions.get(session.id); | ||
| return state?.healthy ?? false; | ||
| } | ||
|
|
||
| async invoke( | ||
| session: McpBridgeSession, | ||
| toolName: string, | ||
| input: Record<string, unknown> | ||
| ): Promise<McpInvokeResult> { | ||
| 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}"`); | ||
| } |
There was a problem hiding this comment.
connect() returns a successful session for non-stdio transports, but invoke() later throws unsupported transport for anything other than stdio. This makes discovery/connect report a server as available/connected even though it can never execute tools. Either implement invocation for http/sse, or treat non-stdio transports as misconfigured/unavailable and fail connect() (and/or surface diagnostics in discover()) until supported.
| try { | ||
| const parsed = JSON.parse(raw) as unknown; | ||
| return McpServerConfigListSchema.parse(parsed); | ||
| } catch { |
There was a problem hiding this comment.
loadMcpServerConfig() silently returns [] on JSON/schema parse failures. That makes MCP unexpectedly “disappear” with no diagnostic, and it’s inconsistent with other env config loaders like loadEmbeddingStrategyConfig() which throw actionable errors on invalid JSON. Consider surfacing a warning/trace (or throwing) when DUBSBOT_MCP_SERVERS_JSON is set but invalid, so misconfiguration is detectable.
| } catch { | |
| } catch (err) { | |
| console.error( | |
| '[loadMcpServerConfig] Failed to parse DUBSBOT_MCP_SERVERS_JSON; MCP servers will be disabled.', | |
| err, | |
| ); |
| ] | ||
| ``` | ||
|
|
||
| Invalid configurations are skipped from active use and surfaced as `unavailable` discovery records with `misconfigured` diagnostics. |
There was a problem hiding this comment.
This rollout guide says invalid configurations are surfaced as unavailable discovery records with misconfigured diagnostics, but if DUBSBOT_MCP_SERVERS_JSON is invalid JSON or fails schema parsing, loadMcpServerConfig() currently returns an empty list and discovery will return no records (no diagnostics). Consider clarifying the doc (or changing the loader behavior) so operators know what to expect when the env var is malformed.
| Invalid configurations are skipped from active use and surfaced as `unavailable` discovery records with `misconfigured` diagnostics. | |
| If `DUBSBOT_MCP_SERVERS_JSON` is missing or malformed (e.g., invalid JSON or fails overall schema parsing), the loader returns no servers and discovery returns an empty list with no diagnostics. Once the env var is successfully parsed, invalid per-server configurations are skipped from active use and surfaced as `unavailable` discovery records with `misconfigured` diagnostics. |
| } | ||
|
|
||
| private async emitAudit(event: McpAuditEvent): Promise<void> { | ||
| await this.deps.auditSink?.append(event); |
There was a problem hiding this comment.
emitAudit() awaits the sink directly; if the audit sink throws (e.g., TraceStore filesystem error or JSON serialization failure), it will cause discover/connect/execute to throw or fail even when the core operation succeeded. Consider making audit emission best-effort (catch and swallow/log sink errors) so observability cannot break the bridge contract.
| await this.deps.auditSink?.append(event); | |
| try { | |
| await this.deps.auditSink?.append(event); | |
| } catch (err) { | |
| // Best-effort: log and swallow audit sink errors so they don't break bridge operations | |
| console.error('Failed to emit MCP audit event:', err); | |
| } |
| 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, | ||
| }); |
There was a problem hiding this comment.
execute() calls policyEngine.evaluateToolInvocation() outside of any try/catch. DefaultPolicyEngine.evaluateToolInvocation() builds a command string using JSON.stringify(invocation.params), which will throw for circular structures or some values (e.g., BigInt). That would make execute() throw without returning a normalized envelope or emitting an audit event. Wrap policy evaluation in a try/catch and map failures into a stable validation/internal error envelope + audit record.
| 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, | |
| }); | |
| let policyDecision: ApprovalDecision; | |
| try { | |
| 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, | |
| }); | |
| } catch (_err) { | |
| const endedAt = this.now(); | |
| const error: McpBridgeError = { | |
| category: 'validation', | |
| message: 'Failed to evaluate policy for tool invocation', | |
| retryable: false, | |
| code: 'policy_evaluation_failed', | |
| }; | |
| const envelope: McpExecutionEnvelope = { | |
| ok: false, | |
| correlationId, | |
| serverId: request.serverId, | |
| toolName: request.toolName, | |
| outputSummary: '', | |
| policyDecision: null, | |
| 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, | |
| error, | |
| metadata: { | |
| input: summarizePayload(request.input), | |
| }, | |
| timing: envelope.timing, | |
| }); | |
| return envelope; | |
| } |
| metadata: { | ||
| input: summarizePayload(request.input), | ||
| output: envelope.outputSummary, | ||
| ...invokeResult.metadata, | ||
| }, |
There was a problem hiding this comment.
The success audit metadata spreads ...invokeResult.metadata directly into the event payload. If adapter metadata contains non-JSON-serializable values (or large/sensitive blobs), TraceStore’s JSON.stringify will throw or produce oversized logs, and because audit emission is awaited this can break the execution flow. Consider sanitizing/serializing adapter metadata (e.g., run through summarizePayload/redactValue, or only allow primitive/JSON-safe metadata) before appending.
🥞 DubStack