From d578cb93d0308bb5b7a4c04fa486af3a87eaaf87 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 27 Jun 2026 09:27:05 +0300 Subject: [PATCH 01/24] =?UTF-8?q?feat(cli):=202.R=20Step=203a=20=E2=80=94?= =?UTF-8?q?=20wire=20inbound=20MCP=20into=20the=20chat=20surface=20(stdio)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect an agent's inline stdio `mcp_servers` into `relavium chat`, `chat-resume`, and `agent run` via the host-side `@relavium/mcp` seam (ADR-0052 §2/§3): the SDK + `node:child_process` stay fenced in `@relavium/mcp`; `packages/core` never sees either. - `engine/mcp-servers.ts`: `resolveStdioServerConfigs` + `connectAgentMcp` — stdio-only for now (a network transport fails loud, the Step-4 follow-up); an `env` value carrying `{{…}}` is rejected loud until named-secret resolution lands (Step 4). A connect/discovery failure is a fail-loud exit-2 CliError whose message is the secret-free MCP summary — the opaque `cause` chain is never attached (host-boundary cause-strip, ADR-0052 §2). - `chat/session-host.ts`: `buildChatSession`/`buildResumedChatSession` are async; they compose the discovered `ToolDef`s into the registry + `deps.tools` and wire the `McpCapability` onto `ToolHost.mcp` (static host assembly, zero engine change). The runtime session binds an EFFECTIVE agent whose grant is unioned with the discovered tool ids (declaring a server implicitly grants its allowlist-narrowed tools, ADR-0052 §3); the persisted snapshot stays the ORIGINAL agent so a resume re-discovers cleanly. - `chat`/`agent-run` await the build, surface dropped tools to stderr (`surfaceMcpSkipped`), and tear the connections down in `finally`. - Packaging (ADR-0051): externalize `@modelcontextprotocol/sdk` (tsup + CLI runtime deps); inline `@relavium/mcp` (workspace devDep). Bundle closure verified. Tests: stdio resolver (transport/env/connect-failure), an end-to-end chat MCP round-trip over the real manager + a fake connection (grant + dispatch routing by original name + teardown), skipped-tool surfacing. lint/typecheck/test (623)/build + bundle-closure + format all green. Refs: ADR-0034, ADR-0052 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/package.json | 2 + apps/cli/src/chat/persister.test.ts | 44 +++---- apps/cli/src/chat/session-host.test.ts | 147 ++++++++++++++++++++---- apps/cli/src/chat/session-host.ts | 113 ++++++++++++++++-- apps/cli/src/commands/agent-run.ts | 11 +- apps/cli/src/commands/chat.test.ts | 51 +++++--- apps/cli/src/commands/chat.ts | 25 +++- apps/cli/src/engine/mcp-servers.test.ts | 126 ++++++++++++++++++++ apps/cli/src/engine/mcp-servers.ts | 122 ++++++++++++++++++++ apps/cli/tsup.config.ts | 5 + pnpm-lock.yaml | 6 + 11 files changed, 577 insertions(+), 75 deletions(-) create mode 100644 apps/cli/src/engine/mcp-servers.test.ts create mode 100644 apps/cli/src/engine/mcp-servers.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index 19a1bcac..e0307fe1 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -39,6 +39,7 @@ "@clack/prompts": "catalog:", "@google/genai": "catalog:", "@jitl/quickjs-singlefile-mjs-release-sync": "catalog:", + "@modelcontextprotocol/sdk": "catalog:", "@napi-rs/keyring": "catalog:", "better-sqlite3": "catalog:", "commander": "catalog:", @@ -55,6 +56,7 @@ "@relavium/core": "workspace:*", "@relavium/db": "workspace:*", "@relavium/llm": "workspace:*", + "@relavium/mcp": "workspace:*", "@relavium/shared": "workspace:*", "@types/node": "catalog:", "@types/react": "catalog:", diff --git a/apps/cli/src/chat/persister.test.ts b/apps/cli/src/chat/persister.test.ts index 563f4785..5f9a9bfc 100644 --- a/apps/cli/src/chat/persister.test.ts +++ b/apps/cli/src/chat/persister.test.ts @@ -40,11 +40,11 @@ describe('createSessionPersister', () => { client.sqlite.close(); }); - function setup(providers: ProviderResolver, initialSequenceNumber?: number) { + async function setup(providers: ProviderResolver, initialSequenceNumber?: number) { let tick = Date.parse('2026-06-25T00:00:00.000Z'); const now = () => tick++; let msgId = 0; - const built = buildChatSession({ + const built = await buildChatSession({ chat: EMPTY_CHAT, agentRef: undefined, cwd: '/workspace', @@ -66,8 +66,8 @@ describe('createSessionPersister', () => { return { built, persister }; } - it('persists the session row eagerly on start (auto-persisted from the moment it starts)', () => { - const { built, persister } = setup(scriptedResolver([textTurn('hi')])); + it('persists the session row eagerly on start (auto-persisted from the moment it starts)', async () => { + const { built, persister } = await setup(scriptedResolver([textTurn('hi')])); persister.start(); const full = store.loadFull('sess-1'); expect(full).toBeDefined(); @@ -78,8 +78,8 @@ describe('createSessionPersister', () => { expect(full?.messages).toHaveLength(0); // no turn yet }); - it('adopts an existing row on start instead of re-INSERTing (resume does not hit the UNIQUE pk)', () => { - const { built, persister } = setup(scriptedResolver([textTurn('hi')]), 5); + it('adopts an existing row on start instead of re-INSERTing (resume does not hit the UNIQUE pk)', async () => { + const { built, persister } = await setup(scriptedResolver([textTurn('hi')]), 5); // Simulate the prior process: the session row already exists in history.db before this resume starts. store.createSession({ id: built.sessionId, @@ -103,7 +103,7 @@ describe('createSessionPersister', () => { it('resumed persister folds new-turn tokens ON TOP of the adopted row totals, not from zero', async () => { // Simulate 2.N resume: a row with prior totals already exists; start() adopts it and hydrates the totals. - const { built, persister } = setup(scriptedResolver([textTurn('go')]), 5); + const { built, persister } = await setup(scriptedResolver([textTurn('go')]), 5); store.createSession({ id: built.sessionId, agentSlug: built.agent.id, @@ -131,7 +131,7 @@ describe('createSessionPersister', () => { // A turn with no provider egress emits NO cost:updated, so the cost flush relies entirely on the hydrated // total. unresolvedResolver fails the turn `internal` before any egress; the prior row cost (1300) must // survive — without start()'s hydration the unconditional flush would reset it to 0. - const { built, persister } = setup(unresolvedResolver(), 5); + const { built, persister } = await setup(unresolvedResolver(), 5); store.createSession({ id: built.sessionId, agentSlug: built.agent.id, @@ -154,7 +154,7 @@ describe('createSessionPersister', () => { }); it('persists a completed turn as a user + text-only assistant pair, and folds the token totals', async () => { - const { built, persister } = setup(scriptedResolver([textTurn('hi there')])); + const { built, persister } = await setup(scriptedResolver([textTurn('hi there')])); persister.start(); built.session.start(); persister.beginUserTurn('hello'); @@ -176,7 +176,7 @@ describe('createSessionPersister', () => { it('persists only the user row when a successful turn produces no assistant text', async () => { // A turn that emits only a stop chunk — zero text_delta, so result.text is empty; the assistantText.length // guard must skip the empty assistant row (mirroring the engine), leaving just the user row. - const { built, persister } = setup(scriptedResolver([[stop('stop')]])); + const { built, persister } = await setup(scriptedResolver([[stop('stop')]])); persister.start(); built.session.start(); persister.beginUserTurn('hello'); @@ -198,7 +198,9 @@ describe('createSessionPersister', () => { { type: 'tool_call_end', id: 'c1' }, stop('tool_use'), ]; - const { built, persister } = setup(scriptedResolver([preToolTurn, textTurn('the answer')])); + const { built, persister } = await setup( + scriptedResolver([preToolTurn, textTurn('the answer')]), + ); persister.start(); built.session.start(); persister.beginUserTurn('go'); @@ -210,7 +212,7 @@ describe('createSessionPersister', () => { }); it('persists nothing for a failed turn (the engine rolls its user message back)', async () => { - const { built, persister } = setup(unresolvedResolver()); // every turn fails `internal` + const { built, persister } = await setup(unresolvedResolver()); // every turn fails `internal` persister.start(); built.session.start(); persister.beginUserTurn('hello'); @@ -221,8 +223,8 @@ describe('createSessionPersister', () => { expect(full?.session.status).toBe('active'); }); - it('marks the session ended on cancel (its sole terminal), leaving it resumable', () => { - const { built, persister } = setup(scriptedResolver([textTurn('hi')])); + it('marks the session ended on cancel (its sole terminal), leaving it resumable', async () => { + const { built, persister } = await setup(scriptedResolver([textTurn('hi')])); persister.start(); built.session.start(); built.session.cancel(); @@ -230,7 +232,7 @@ describe('createSessionPersister', () => { }); it('produces a transcript reconstructSessionState round-trips into a resumable state', async () => { - const { built, persister } = setup(scriptedResolver([textTurn('hi there')])); + const { built, persister } = await setup(scriptedResolver([textTurn('hi there')])); persister.start(); built.session.start(); persister.beginUserTurn('hello'); @@ -250,7 +252,7 @@ describe('createSessionPersister', () => { }); it('accumulates sequenceNumber and token totals across consecutive turns', async () => { - const { built, persister } = setup( + const { built, persister } = await setup( scriptedResolver([textTurn('reply 1'), textTurn('reply 2')]), ); persister.start(); @@ -272,7 +274,7 @@ describe('createSessionPersister', () => { }); it('seeds the first sequenceNumber from initialSequenceNumber (the 2.N resume injection point)', async () => { - const { built, persister } = setup(scriptedResolver([textTurn('hi')]), 5); + const { built, persister } = await setup(scriptedResolver([textTurn('hi')]), 5); persister.start(); built.session.start(); persister.beginUserTurn('go'); @@ -282,7 +284,7 @@ describe('createSessionPersister', () => { }); it('start() is idempotent — a second call neither duplicates the row nor double-subscribes', async () => { - const { built, persister } = setup(scriptedResolver([textTurn('hi')])); + const { built, persister } = await setup(scriptedResolver([textTurn('hi')])); persister.start(); persister.start(); // a duplicate createSession would PK-violate; a double-subscribe would double-write built.session.start(); @@ -292,7 +294,7 @@ describe('createSessionPersister', () => { }); it('close() unsubscribes — turns after close are not persisted (the session stays in the db)', async () => { - const { built, persister } = setup(scriptedResolver([textTurn('one'), textTurn('two')])); + const { built, persister } = await setup(scriptedResolver([textTurn('one'), textTurn('two')])); persister.start(); built.session.start(); persister.beginUserTurn('first'); @@ -308,7 +310,7 @@ describe('createSessionPersister', () => { it('flushes the running cost on a failed turn so a resumed budget governor sees the true spend', async () => { // Turn 1 succeeds and incurs a real (priced) cost; turn 2 is unscripted, so the provider throws and the // turn settles as an error. The d6b975b unconditional flush must keep the turn-1 cost durable (not 0). - const { built, persister } = setup(scriptedResolver([textTurn('hi')])); + const { built, persister } = await setup(scriptedResolver([textTurn('hi')])); persister.start(); built.session.start(); persister.beginUserTurn('first'); @@ -325,7 +327,7 @@ describe('createSessionPersister', () => { }); it('on cancel after a successful turn: status ended, totals retained, messages untouched', async () => { - const { built, persister } = setup(scriptedResolver([textTurn('hi there')])); + const { built, persister } = await setup(scriptedResolver([textTurn('hi there')])); persister.start(); built.session.start(); persister.beginUserTurn('hello'); diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index 937af31d..8bf31627 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -1,5 +1,10 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { BudgetExceededError, BudgetPauseError } from '@relavium/core'; import type { SessionStreamHandleEvent } from '@relavium/core'; +import { startMcpClient as realStartMcpClient, type McpConnection } from '@relavium/mcp'; import type { AgentSessionRecord, SessionMessage } from '@relavium/shared'; import { describe, expect, it } from 'vitest'; @@ -48,8 +53,8 @@ function build(overrides: Partial[0]> = {}) } describe('buildChatSession', () => { - it('mints the session over the default agent + a handle scoped to the same id', () => { - const built = build({ chat: { ...EMPTY_CHAT, defaultModel: 'claude-sonnet-4-6' } }); + it('mints the session over the default agent + a handle scoped to the same id', async () => { + const built = await build({ chat: { ...EMPTY_CHAT, defaultModel: 'claude-sonnet-4-6' } }); expect(built.sessionId).toBe('sess-test-1'); expect(built.handle.sessionId).toBe('sess-test-1'); expect(built.agent.id).toBe('relavium-chat'); @@ -58,15 +63,15 @@ describe('buildChatSession', () => { expect(built.context.fsScopeTier).toBe('sandboxed'); // default when [chat].fs_scope is unset }); - it('honors [chat].fs_scope on the SessionContext', () => { - const built = build({ chat: { ...EMPTY_CHAT, fsScope: 'project' } }); + it('honors [chat].fs_scope on the SessionContext', async () => { + const built = await build({ chat: { ...EMPTY_CHAT, fsScope: 'project' } }); expect(built.context.fsScopeTier).toBe('project'); }); - it('a subscribe()-wired listener observes session:started synchronously (the driveInk ordering contract)', () => { + it('a subscribe()-wired listener observes session:started synchronously (the driveInk ordering contract)', async () => { // driveInk subscribes BEFORE startSession so the synchronous session:started (which carries the model for // the footer) is not raced. This locks that the bus emits session:started inline on session.start(). - const built = build({ chat: { ...EMPTY_CHAT, defaultModel: 'claude-sonnet-4-6' } }); + const built = await build({ chat: { ...EMPTY_CHAT, defaultModel: 'claude-sonnet-4-6' } }); const received: SessionStreamHandleEvent[] = []; const off = built.handle.subscribe((e) => received.push(e)); built.session.start(); @@ -80,7 +85,7 @@ describe('buildChatSession', () => { }); it('streams a text turn end-to-end through the handle (started → tokens → cost → completed → cancelled)', async () => { - const built = build({ providers: scriptedResolver([textTurn('hello there')]) }); + const built = await build({ providers: scriptedResolver([textTurn('hello there')]) }); built.session.start(); await built.session.sendMessage('hi'); built.session.cancel(); @@ -102,7 +107,7 @@ describe('buildChatSession', () => { it('streams a tool-calling turn: the model calls a granted tool, the loop completes, the answer streams', async () => { // Turn 1 calls read_file (a default-agent grant) → dispatched through the fail-closed {} host (a // tool_result, unavailable) → turn 2 streams the final answer. The agent:tool_call annotation fires. - const built = build({ + const built = await build({ providers: scriptedResolver([toolUseTurn('c1', 'read_file'), textTurn('the answer')]), }); built.session.start(); @@ -121,7 +126,7 @@ describe('buildChatSession', () => { it('enforces [chat].max_turns: an over-cap sendMessage settles loudly as turn_limit with no provider call', async () => { // unresolvedResolver ⇒ every turn fails fast as `internal` (a host-wiring gap) but still COUNTS toward // the cap, so the 3rd message past a cap of 2 is blocked as turn_limit without engaging a provider. - const built = build({ + const built = await build({ chat: { ...EMPTY_CHAT, maxTurns: 2 }, providers: unresolvedResolver(), }); @@ -139,6 +144,99 @@ describe('buildChatSession', () => { }); }); +describe('buildChatSession + MCP host wiring (2.R)', () => { + /** Write a throwaway `.agent.yaml` declaring one inline stdio MCP server, returning its path. */ + function writeMcpAgent(): string { + const dir = mkdtempSync(join(tmpdir(), 'relavium-mcp-')); + const path = join(dir, 'a.agent.yaml'); + writeFileSync( + path, + [ + 'id: mcp-agent', + 'model: claude-sonnet-4-6', + 'provider: anthropic', + 'system_prompt: test agent', + 'tools:', + ' - read_file', + 'mcp_servers:', + ' - id: fs', + ' transport: stdio', + ' command: my-server', + '', + ].join('\n'), + ); + return path; + } + + it('grants the discovered tool, routes a call to the connection by original name, and tears it down', async () => { + const calls: { name: string; args: unknown }[] = []; + let closed = 0; + const conn: McpConnection = { + listTools: () => Promise.resolve([{ name: 'read', inputSchema: { type: 'object' } }]), + callTool: (name, args) => { + calls.push({ name, args }); + return Promise.resolve({ content: [{ type: 'text', text: 'file body' }], isError: false }); + }, + close: () => { + closed += 1; + return Promise.resolve(); + }, + }; + const built = await build({ + agentRef: writeMcpAgent(), + providers: scriptedResolver([toolUseTurn('c1', 'mcp_fs_read'), textTurn('done')]), + // The injected starter runs the REAL manager over a FAKE connection — no child is spawned, but the real + // namespacing (`mcp_fs_read`), the dispatch routing, and the close all execute. + startMcpClient: () => realStartMcpClient([{ id: 'fs', open: () => Promise.resolve(conn) }]), + }); + + // The RETURNED agent is the ORIGINAL — its grant is not mutated with the dynamic id (so the persisted + // snapshot stays the author's agent; the runtime session binds the augmented one). + expect(built.agent.tools).toEqual(['read_file']); + expect(built.mcpSkipped).toEqual([]); + expect(typeof built.closeMcp).toBe('function'); + + built.session.start(); + await built.session.sendMessage('go'); + built.session.cancel(); + const events = await drainHandle(built.handle.events); + + const toolCall = events.find((e) => e.type === 'agent:tool_call'); + expect(toolCall?.type === 'agent:tool_call' && toolCall.toolId).toBe('mcp_fs_read'); + expect(calls).toEqual([{ name: 'read', args: {} }]); // routed to the ORIGINAL server name, not the id + + await built.closeMcp?.(); + expect(closed).toBe(1); + }); + + it('leaves closeMcp undefined + mcpSkipped empty when the agent declares no mcp_servers', async () => { + const built = await build({ chat: { ...EMPTY_CHAT, defaultModel: 'claude-sonnet-4-6' } }); + expect(built.closeMcp).toBeUndefined(); + expect(built.mcpSkipped).toEqual([]); + }); + + it('surfaces a tool dropped at discovery via mcpSkipped (allowlist-excluded — non-fatal)', async () => { + const conn: McpConnection = { + listTools: () => + Promise.resolve([ + { name: 'read', inputSchema: { type: 'object' } }, + { name: 'danger', inputSchema: { type: 'object' } }, + ]), + callTool: () => Promise.resolve({ content: [], isError: false }), + close: () => Promise.resolve(), + }; + const built = await build({ + agentRef: writeMcpAgent(), + startMcpClient: () => + realStartMcpClient([ + { id: 'fs', toolsAllowlist: ['read'], open: () => Promise.resolve(conn) }, + ]), + }); + expect(built.mcpSkipped.map((s) => s.name)).toContain('danger'); // excluded by the allowlist + await built.closeMcp?.(); + }); +}); + describe('buildResumedChatSession (2.N)', () => { const RESUME_AGENT = buildDefaultChatAgent('claude-sonnet-4-6'); const ISO = '2026-06-25T00:00:00.000Z'; @@ -176,8 +274,8 @@ describe('buildResumedChatSession (2.N)', () => { }); } - it('rebinds the frozen agent + context and reconstructs the carried-over state', () => { - const built = resume([message(0, 'user', 'hi'), message(1, 'assistant', 'hello')]); + it('rebinds the frozen agent + context and reconstructs the carried-over state', async () => { + const built = await resume([message(0, 'user', 'hi'), message(1, 'assistant', 'hello')]); expect(built.sessionId).toBe('sess-r'); expect(built.agent.id).toBe(RESUME_AGENT.id); expect(built.agent.model).toBe('claude-sonnet-4-6'); @@ -188,16 +286,16 @@ describe('buildResumedChatSession (2.N)', () => { expect(built.nextSequenceNumber).toBe(2); }); - it('continues a transcript whose last persisted seq is computed from the MAX (order-independent)', () => { + it('continues a transcript whose last persisted seq is computed from the MAX (order-independent)', async () => { // Rows passed out of order; nextSequenceNumber must be MAX+1, not last-element+1. - const built = resume([message(1, 'assistant', 'hello'), message(0, 'user', 'hi')]); + const built = await resume([message(1, 'assistant', 'hello'), message(0, 'user', 'hi')]); expect(built.nextSequenceNumber).toBe(2); }); - it('computes nextSequenceNumber from the MAX, not the row COUNT (a gapped transcript)', () => { + it('computes nextSequenceNumber from the MAX, not the row COUNT (a gapped transcript)', async () => { // A gapped transcript (seq 0,1,5 — length 3 but MAX 5) pins MAX+1 semantics: a count-based bug // (`messages.length`) would yield 3 and collide; the correct answer is 6. - const built = resume([ + const built = await resume([ message(0, 'user', 'hi'), message(1, 'assistant', 'hello'), message(5, 'user', 'later'), @@ -205,11 +303,11 @@ describe('buildResumedChatSession (2.N)', () => { expect(built.nextSequenceNumber).toBe(6); }); - it('rolls back a trailing unanswered user turn but still continues past its durable seq', () => { + it('rolls back a trailing unanswered user turn but still continues past its durable seq', async () => { // A session interrupted after the user typed (assistant never replied): the durable transcript ends in a // dangling user row (seq 2). reconstruct rolls it back (so the model is not re-sent two user turns), but // the persister must still continue PAST that durable row (seq 3), never overwrite it. - const built = resume([ + const built = await resume([ message(0, 'user', 'hi'), message(1, 'assistant', 'hello'), message(2, 'user', 'dangling'), @@ -219,14 +317,14 @@ describe('buildResumedChatSession (2.N)', () => { expect(built.nextSequenceNumber).toBe(3); // continues past the durable orphan row, not over it }); - it('starts a never-messaged session at sequence 0', () => { - const built = resume([]); + it('starts a never-messaged session at sequence 0', async () => { + const built = await resume([]); expect(built.nextSequenceNumber).toBe(0); expect(built.resumeState.turnCount).toBe(0); }); it('the resumed session lands at idle and continues WITHOUT re-emitting session:started', async () => { - const built = resume([message(0, 'user', 'hi'), message(1, 'assistant', 'hello')]); + const built = await resume([message(0, 'user', 'hi'), message(1, 'assistant', 'hello')]); // No start() — AgentSession.resume already landed at idle; sendMessage continues the conversation. await built.session.sendMessage('again'); built.session.cancel(); @@ -242,7 +340,7 @@ describe('buildResumedChatSession (2.N)', () => { it('rebinds the agent from the SNAPSHOT, not the record agentSlug (which may diverge after a rename)', async () => { // agentSlug diverges from the snapshot id; the resumed session must bind the SNAPSHOT's id, and the // live events' nodeId (= agentRef = agent.id) must follow the snapshot — not the stale slug. - const built = resume( + const built = await resume( [message(0, 'user', 'hi'), message(1, 'assistant', 'hello')], record({ agentSlug: 'old-slug' }), ); @@ -259,7 +357,7 @@ describe('buildResumedChatSession (2.N)', () => { // A near-exhausted record (totalCostMicrocents far past a 1µ¢ cap): AgentSession.resume seeds the governor // via updateCost(carried), so the FIRST resumed turn's pre-egress check trips BEFORE any new cost:updated. // Without that seeding the carried spend would be invisible and the first turn would slip past the cap. - const built = buildResumedChatSession({ + const built = await buildResumedChatSession({ chat: { ...EMPTY_CHAT, maxCostMicrocents: 1, onExceed: 'fail' }, record: record({ totalCostMicrocents: 999_999 }), messages: [message(0, 'user', 'hi'), message(1, 'assistant', 'hello')], @@ -276,8 +374,9 @@ describe('buildResumedChatSession (2.N)', () => { expect(errorCodes).toContain('budget_exceeded'); // tripped on the carried cost, no provider call }); - it('rejects a record with no stored agent snapshot as a clean exit-2 invocation fault', () => { - expect(() => resume([], record({ agentSnapshot: undefined }))).toThrow( + it('rejects a record with no stored agent snapshot as a clean exit-2 invocation fault', async () => { + // The build is async now (2.R MCP connect), so the no-snapshot guard surfaces as a REJECTED promise. + await expect(resume([], record({ agentSnapshot: undefined }))).rejects.toThrow( /no stored agent snapshot/, ); }); diff --git a/apps/cli/src/chat/session-host.ts b/apps/cli/src/chat/session-host.ts index c993378b..07319b75 100644 --- a/apps/cli/src/chat/session-host.ts +++ b/apps/cli/src/chat/session-host.ts @@ -14,9 +14,11 @@ import { type SessionResumeState, type ToolHost, } from '@relavium/core'; +import type { ManagerSkippedTool, McpClient, McpServerConfig } from '@relavium/mcp'; import type { AgentSessionRecord, Budget, SessionContext, SessionMessage } from '@relavium/shared'; import type { ResolvedChatConfig } from '../config/resolve.js'; +import { connectAgentMcp } from '../engine/mcp-servers.js'; import { createProviderResolver, type ProviderResolver } from '../engine/providers.js'; import { CliError } from '../process/errors.js'; import { resolveChatAgent } from './agent-source.js'; @@ -49,6 +51,12 @@ export interface BuildChatSessionOptions { readonly providers?: ProviderResolver; /** The tool-execution host (injectable for tests); defaults to fail-closed `{}` (capabilities are a follow-up). */ readonly toolHost?: ToolHost; + /** + * Injectable MCP connect-all (2.R) — tests pass a fake that never spawns a child; production uses the real + * `@relavium/mcp` `startMcpClient`. Threads through to {@link connectAgentMcp} so the agent's inline stdio + * `mcp_servers` discover their tools without a live server in the unit path. + */ + readonly startMcpClient?: (servers: readonly McpServerConfig[]) => Promise; /** * Session-scoped `{{ctx.*}}` variables (plaintext, NO secrets — agent-session-spec.md §Tools). `relavium * agent run --input k=v` (2.Q) populates these; a bare `chat` leaves them unset. @@ -73,7 +81,13 @@ export interface BuiltChatSession { readonly session: AgentSession; readonly handle: SessionHandle; readonly sessionId: string; - /** The bound agent (resolved `--agent` or the built-in default) — its `id` is the session's `agentRef`. */ + /** + * The bound agent — its `id` is the session's `agentRef`. This is the **ORIGINAL** resolved agent (it carries + * `mcp_servers` but NOT the dynamically-discovered MCP tool ids), so the persisted snapshot and the + * export-to-workflow scaffold record the author's agent — a `chat-resume` re-discovers the live tools from + * `mcp_servers` rather than replaying a stale baked grant. The runtime session is bound to an *effective* agent + * whose `tools` is unioned with the discovered MCP ids (2.R), constructed internally and not surfaced here. + */ readonly agent: AgentDefinition; /** The frozen session context (working dir + fs-scope tier) the session ran against. */ readonly context: SessionContext; @@ -83,6 +97,16 @@ export interface BuiltChatSession { * `--json`; the bus stamps the `sessionId`/`sequenceNumber`/`timestamp`. */ readonly emitSessionEvent: SessionEventSink; + /** + * Tear down the session's MCP connections (2.R) — present only when the agent declared `mcp_servers`. The + * command MUST `await` it on session teardown (its `finally`), mirroring `persister.close()`. Idempotent. + */ + readonly closeMcp?: () => Promise; + /** + * Tools dropped at MCP discovery (allowlist / unsupported schema / collision / unsafe id) — a non-fatal + * diagnostic the command surfaces to the user (stderr). Empty when no MCP server is declared. + */ + readonly mcpSkipped: readonly ManagerSkippedTool[]; } /** The safe default filesystem tier when `[chat].fs_scope` is unset (mirrors the workflow default). */ @@ -100,14 +124,23 @@ type SessionRuntimeOptions = Pick< * one-bus-two-namespaces) and the {@link SessionDeps} (provider seam, tool registry, the hard turn cap, and — * when a cost cap is configured — the ADR-0028 pre-egress governor). Shared by {@link buildChatSession} (fresh) * and {@link buildResumedChatSession} (2.N resume) so the two paths can never wire different capabilities. + * + * When the agent declared `mcp_servers` (2.R), the live {@link McpClient}'s namespaced `ToolDef`s are composed + * into BOTH the registry (so dispatch can resolve them) and `deps.tools` (so the granted set is surfaced to the + * LLM), and its `McpCapability` is wired onto `ToolHost.mcp` (so a `tools/call` routes to the owning connection) + * — host-side static assembly with zero engine change ([ADR-0052](../../../docs/decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §3). */ function buildSessionRuntime( opts: SessionRuntimeOptions, sessionId: string, + mcp: McpClient | undefined, ): { bus: RunEventBus; deps: SessionDeps; emit: SessionEventSink } { const bus = new RunEventBus({ now: () => new Date(opts.now()).toISOString() }); const providers = opts.providers ?? createProviderResolver(); - const registry = createToolRegistry({ tools: BUILTIN_TOOLS, host: opts.toolHost ?? {} }); + const tools = mcp === undefined ? BUILTIN_TOOLS : [...BUILTIN_TOOLS, ...mcp.toolDefs]; + const host: ToolHost = + mcp === undefined ? (opts.toolHost ?? {}) : { ...(opts.toolHost ?? {}), mcp: mcp.capability }; + const registry = createToolRegistry({ tools, host }); const governor = buildGovernorWiring(opts.chat, opts.onBudgetWarning); // The session event sink (1.W): a draft → bus → stamped sequenceNumber/timestamp. Hoisted so a SURFACE // event (the in-REPL `/export`'s `session:exported`, 2.Q) can ride the same monotonic per-session counter. @@ -117,7 +150,7 @@ function buildSessionRuntime( resolveProvider: providers.resolveProvider, keyFor: providers.keyFor, registry, - tools: BUILTIN_TOOLS, + tools, sleep: (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms)), now: opts.now, // Node's AbortController satisfies the engine's structural AbortControllerLike (abort() + signal). @@ -134,7 +167,7 @@ function buildSessionRuntime( return { bus, deps, emit }; } -export function buildChatSession(opts: BuildChatSessionOptions): BuiltChatSession { +export async function buildChatSession(opts: BuildChatSessionOptions): Promise { const sessionId = opts.uuid(); const agent = resolveChatAgent(opts.agentRef, { cwd: opts.cwd, @@ -147,10 +180,49 @@ export function buildChatSession(opts: BuildChatSessionOptions): BuiltChatSessio ...(opts.variables === undefined ? {} : { variables: opts.variables }), }; - const { bus, deps, emit } = buildSessionRuntime(opts, sessionId); - const session = new AgentSession({ sessionId, agentRef: agent.id, agent, context, deps }); + // Connect the agent's inline stdio `mcp_servers` (2.R) — fail-loud (a connect/discovery failure throws a + // typed exit-2 CliError). `undefined` when none are declared (no client, nothing to tear down). The spawn + // cwd is the session working dir, so a relative server path resolves against the workspace. + const mcp = await connectAgentMcp(agent.mcp_servers, { + cwd: opts.cwd, + ...(opts.startMcpClient === undefined ? {} : { startMcpClient: opts.startMcpClient }), + }); + + const { bus, deps, emit } = buildSessionRuntime(opts, sessionId, mcp); + // The session runs against the EFFECTIVE agent (its grant unioned with the discovered MCP tool ids); the + // ORIGINAL `agent` is what we return + persist (see {@link BuiltChatSession.agent}). + const session = new AgentSession({ + sessionId, + agentRef: agent.id, + agent: withMcpGrant(agent, mcp), + context, + deps, + }); const handle = createSessionHandle(bus, sessionId, () => session.cancel()); - return { session, handle, sessionId, agent, context, emitSessionEvent: emit }; + return { + session, + handle, + sessionId, + agent, + context, + emitSessionEvent: emit, + mcpSkipped: mcp?.skipped ?? [], + ...(mcp === undefined ? {} : { closeMcp: () => mcp.close() }), + }; +} + +/** + * Return the agent the runtime session binds: the original `agent` with its `tools` grant **unioned** with the + * discovered MCP tool ids (2.R). Declaring an `mcp_servers` entry implicitly grants that server's discovered + * (already `tools_allowlist`-narrowed) tools — the only coherent grant, since the namespaced ids are discovered + * dynamically and cannot be pre-listed in `tools:` ([ADR-0052](../../../docs/decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §3, + * "the agent's `tools:` grant AND `tools_allowlist` narrow … with zero engine-interface change"). Built-ins stay + * governed by `tools:`. Returns the agent unchanged when no MCP tools were discovered. + */ +function withMcpGrant(agent: AgentDefinition, mcp: McpClient | undefined): AgentDefinition { + if (mcp === undefined || mcp.toolDefs.length === 0) return agent; + const tools = [...new Set([...(agent.tools ?? []), ...mcp.toolDefs.map((def) => def.id)])]; + return { ...agent, tools }; } /** A resumed session (2.N) plus the two extra facts the REPL needs: the reconstructed state + the next seq. */ @@ -181,6 +253,8 @@ export interface BuildResumedChatSessionOptions { readonly providers?: ProviderResolver; /** The tool-execution host (injectable for tests); defaults to fail-closed `{}`. */ readonly toolHost?: ToolHost; + /** Injectable MCP connect-all (2.R; see {@link BuildChatSessionOptions.startMcpClient}). */ + readonly startMcpClient?: (servers: readonly McpServerConfig[]) => Promise; /** Sink for an `on_exceed: 'warn'` pre-egress budget warning (see {@link BuildChatSessionOptions}). */ readonly onBudgetWarning?: (warning: ChatBudgetWarning) => void; } @@ -193,9 +267,9 @@ export interface BuildResumedChatSessionOptions { * NOT re-emit `session:started`; the next `sendMessage` continues the conversation. A session with no stored * `agentSnapshot` cannot be rebound and is a clean invalid invocation (exit 2). */ -export function buildResumedChatSession( +export async function buildResumedChatSession( opts: BuildResumedChatSessionOptions, -): BuiltResumedChatSession { +): Promise { const { record, messages } = opts; const agent = record.agentSnapshot; if (agent === undefined) { @@ -207,9 +281,24 @@ export function buildResumedChatSession( const context = record.context; const resumeState = reconstructSessionState(record, messages); - const { bus, deps, emit } = buildSessionRuntime(opts, record.id); + // Re-discover the frozen agent's `mcp_servers` fresh each resume (2.R) — the snapshot stored the author's + // agent, NOT a baked tool grant, so a server whose tool set changed is picked up correctly. The spawn cwd is + // the session's frozen working dir. Connect last (after the sync reconstruct/validate) so a reconstruct fault + // never leaks an opened connection. + const mcp = await connectAgentMcp(agent.mcp_servers, { + cwd: context.workingDir, + ...(opts.startMcpClient === undefined ? {} : { startMcpClient: opts.startMcpClient }), + }); + + const { bus, deps, emit } = buildSessionRuntime(opts, record.id, mcp); const session = AgentSession.resume( - { sessionId: record.id, agentRef: agent.id, agent, context, deps }, + { + sessionId: record.id, + agentRef: agent.id, + agent: withMcpGrant(agent, mcp), + context, + deps, + }, resumeState, ); const handle = createSessionHandle(bus, record.id, () => session.cancel()); @@ -228,6 +317,8 @@ export function buildResumedChatSession( emitSessionEvent: emit, resumeState, nextSequenceNumber, + mcpSkipped: mcp?.skipped ?? [], + ...(mcp === undefined ? {} : { closeMcp: () => mcp.close() }), }; } diff --git a/apps/cli/src/commands/agent-run.ts b/apps/cli/src/commands/agent-run.ts index e5ed72ca..2efd2f00 100644 --- a/apps/cli/src/commands/agent-run.ts +++ b/apps/cli/src/commands/agent-run.ts @@ -11,7 +11,7 @@ import { CliError } from '../process/errors.js'; import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; -import { makePlainPrinter } from './chat.js'; +import { makePlainPrinter, surfaceMcpSkipped } from './chat.js'; /** * `relavium agent run ` (2.Q) — invoke a single agent **one-shot** (non-interactive) on the same @@ -81,8 +81,10 @@ export async function agentRunCommand( ? (deps.providers ?? createProviderResolver(deps.io.env)) : cassetteResolver(loadCassette(args.fixture, deps.global.cwd)); - // An unknown `` (path or id) throws a typed CliError here (exit 2), before any turn. - const built = (deps.buildSession ?? buildChatSession)({ + // An unknown `` (path or id) throws a typed CliError here (exit 2), before any turn. The build is + // async (2.R): it connects the agent's inline stdio `mcp_servers` (a connect failure is a fail-loud exit-2 + // CliError, cause stripped) before the one-shot turn runs. + const built = await (deps.buildSession ?? buildChatSession)({ chat: config.chat, agentRef: args.agent, cwd: deps.global.cwd, @@ -91,6 +93,7 @@ export async function agentRunCommand( uuid, providers, }); + surfaceMcpSkipped(deps.io, built.mcpSkipped); // Render the live stream (NDJSON under --json, else the plain token/tool printer) and capture the turn // outcome — a classified turn failure completes with `session:turn_completed.error`, mapping to exit 1. @@ -120,6 +123,8 @@ export async function agentRunCommand( } finally { built.session.cancel(); // the session's terminal (session:cancelled) — closes the one-shot cleanly unsubscribe(); + // Tear down the MCP connections (2.R) after the one-shot turn; present only when `mcp_servers` is declared. + await built.closeMcp?.(); } return turnErrorCode === undefined ? EXIT_CODES.success : EXIT_CODES.workflowFailed; } diff --git a/apps/cli/src/commands/chat.test.ts b/apps/cli/src/commands/chat.test.ts index 0f909eb2..8b4ec4c2 100644 --- a/apps/cli/src/commands/chat.test.ts +++ b/apps/cli/src/commands/chat.test.ts @@ -27,6 +27,7 @@ import { driveJson, drivePlain, makePlainPrinter, + surfaceMcpSkipped, type ChatCommandDeps, type ChatDriveContext, type ChatDriver, @@ -643,8 +644,8 @@ describe('chatResumeCommand (2.N)', () => { describe('drivePlain', () => { // A minimal driver context over a REAL session handle (no turns fire — startSession is a no-op) plus a // recording processLine, so we exercise drivePlain's readline loop + teardown over the injected stdin (F1). - function plainCtx(stdin: NodeJS.ReadableStream) { - const built = buildChatSession({ + async function plainCtx(stdin: NodeJS.ReadableStream) { + const built = await buildChatSession({ chat: EMPTY_CHAT, agentRef: undefined, cwd: tmpdir(), @@ -674,7 +675,7 @@ describe('drivePlain', () => { it('reads lines from the injected stdin, dispatches each, and stops on /exit', async () => { const stdin = new PassThrough(); - const { ctx, processed } = plainCtx(stdin); + const { ctx, processed } = await plainCtx(stdin); const done = drivePlain(ctx); stdin.write('hello\n'); stdin.write('/exit\n'); @@ -685,7 +686,7 @@ describe('drivePlain', () => { it('a SIGINT closes the input so the loop ends and the finally removes the handler (teardown path)', async () => { const stdin = new PassThrough(); - const { ctx } = plainCtx(stdin); + const { ctx } = await plainCtx(stdin); // Identify drivePlain's handler by SET-DELTA (not `.at(-1)`), matching the run.test.ts pattern — robust to // any other SIGINT listener the runner/host registers around it. Invoke it directly (not process.emit, // which would also fire the runner's listeners); it calls rl.close(), ending the for-await loop. @@ -782,8 +783,8 @@ describe('selectChatDriver', () => { // A ctx whose stdin is already at EOF, so the PLAIN driver resolves immediately. The ink driver would mount // and block on input forever, so a resolving promise PROVES the plain branch was chosen. If the routing // predicate regressed (e.g. && → ||), a non-TTY case would route to ink and hang this test. - function ctxWith(stdoutIsTty: boolean, json: boolean): ChatDriveContext { - const built = buildChatSession({ + async function ctxWith(stdoutIsTty: boolean, json: boolean): Promise { + const built = await buildChatSession({ chat: EMPTY_CHAT, agentRef: undefined, cwd: tmpdir(), @@ -807,23 +808,26 @@ describe('selectChatDriver', () => { } it('routes a non-TTY surface to the plain driver (resolves; ink would block)', async () => { - await expect(selectChatDriver(ctxWith(false, false))).resolves.toBeUndefined(); + await expect(selectChatDriver(await ctxWith(false, false))).resolves.toBeUndefined(); }); it('routes --json to the headless json driver even on a TTY (resolves; ink would block)', async () => { - await expect(selectChatDriver(ctxWith(true, true))).resolves.toBeUndefined(); + await expect(selectChatDriver(await ctxWith(true, true))).resolves.toBeUndefined(); }); it('routes a non-TTY + --json surface to the headless json driver', async () => { - await expect(selectChatDriver(ctxWith(false, true))).resolves.toBeUndefined(); + await expect(selectChatDriver(await ctxWith(false, true))).resolves.toBeUndefined(); }); }); describe('driveJson (2.Q)', () => { // A driver context over a REAL session handle + a recording processLine, so we exercise driveJson's // readline loop and its NDJSON serialization of the live event stream over the injected stdin. - function jsonCtx(stdin: NodeJS.ReadableStream, turns: StreamChunk[][] = [textTurn('hi there')]) { - const built = buildChatSession({ + async function jsonCtx( + stdin: NodeJS.ReadableStream, + turns: StreamChunk[][] = [textTurn('hi there')], + ) { + const built = await buildChatSession({ chat: EMPTY_CHAT, agentRef: undefined, cwd: tmpdir(), @@ -856,7 +860,7 @@ describe('driveJson (2.Q)', () => { it('emits a pure NDJSON stream (session:started → turn events → session:cancelled terminal) on EOF', async () => { const stdin = new PassThrough(); - const { ctx, out } = jsonCtx(stdin); + const { ctx, out } = await jsonCtx(stdin); const done = driveJson(ctx); stdin.write('hello\n'); stdin.end(); // EOF ends the loop @@ -876,7 +880,7 @@ describe('driveJson (2.Q)', () => { it('streams two turns, each with its own session:turn_completed, before the terminal', async () => { const stdin = new PassThrough(); - const { ctx, out } = jsonCtx(stdin, [textTurn('one'), textTurn('two')]); + const { ctx, out } = await jsonCtx(stdin, [textTurn('one'), textTurn('two')]); const done = driveJson(ctx); stdin.write('first\n'); stdin.write('second\n'); @@ -892,7 +896,7 @@ describe('driveJson (2.Q)', () => { // The parallel of the drivePlain SIGINT teardown test — driveJson registers its own SIGINT handler and must // remove it in the finally. Identify it by SET-DELTA (robust to any other listener the runner registers). const stdin = new PassThrough(); - const { ctx } = jsonCtx(stdin); + const { ctx } = await jsonCtx(stdin); const before = process.listeners('SIGINT').slice(); const done = driveJson(ctx); const added = process.listeners('SIGINT').filter((l) => !before.includes(l)); @@ -904,3 +908,22 @@ describe('driveJson (2.Q)', () => { expect(process.listeners('SIGINT').filter((l) => !before.includes(l))).toHaveLength(0); // finally removed it }); }); + +describe('surfaceMcpSkipped (2.R)', () => { + it('writes one secret-free stderr note per dropped tool (name + server + reason), nothing on an empty list', () => { + const { io, err, out } = captureIo(); + surfaceMcpSkipped(io, []); + expect(err()).toBe(''); // no MCP servers / nothing dropped ⇒ silent (the common case) + + surfaceMcpSkipped(io, [ + { server: 'fs', name: 'danger', reason: 'not in tools_allowlist' }, + { server: 'gh', name: 'bad-id!', reason: 'unsafe LLM tool name' }, + ]); + const lines = err().trimEnd().split('\n'); + expect(lines).toHaveLength(2); + expect(lines[0]).toContain("MCP tool 'danger' (server 'fs')"); + expect(lines[0]).toContain('not in tools_allowlist'); + expect(lines[1]).toContain("MCP tool 'bad-id!' (server 'gh')"); + expect(out()).toBe(''); // diagnostics stay on stderr — stdout (the --json stream) is untouched + }); +}); diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index 47413dca..79b9891f 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -6,6 +6,7 @@ import { type SessionHandle, type SessionStreamHandleEvent, } from '@relavium/core'; +import type { ManagerSkippedTool } from '@relavium/mcp'; import { exportSession } from '../chat/export.js'; import { createSessionPersister, type SessionPersister } from '../chat/persister.js'; @@ -131,7 +132,9 @@ export async function chatCommand(args: ChatCommandArgs, deps: ChatCommandDeps): const store = createChatStore(deps.global.color); // An unknown --agent / un-inferrable default model throws a typed CliError here (exit 2), before any session. - const built = (deps.buildSession ?? buildChatSession)({ + // The build is async (2.R): it connects the agent's inline stdio `mcp_servers` (a connect failure is a + // fail-loud exit-2 CliError, cause stripped) before the session is live. + const built = await (deps.buildSession ?? buildChatSession)({ chat: config.chat, agentRef: args.agent, cwd: deps.global.cwd, @@ -144,6 +147,7 @@ export async function chatCommand(args: ChatCommandArgs, deps: ChatCommandDeps): `budget warning: ~${warning.thresholdPct}% of the ${warning.limitMicrocents}µ¢ cap reached\n`, ), }); + surfaceMcpSkipped(deps.io, built.mcpSkipped); const opened = (deps.openSessionStore ?? openSessionStore)(homeDir); const persister = createSessionPersister({ @@ -195,7 +199,7 @@ export async function chatResumeCommand( if (loaded === undefined) { throw new CliError('invalid_invocation', `no session found with id ${args.sessionId}`); } - const resumed = (deps.buildResumedSession ?? buildResumedChatSession)({ + const resumed = await (deps.buildResumedSession ?? buildResumedChatSession)({ chat: config.chat, record: loaded.session, messages: loaded.messages, @@ -206,6 +210,7 @@ export async function chatResumeCommand( `budget warning: ~${warning.thresholdPct}% of the ${warning.limitMicrocents}µ¢ cap reached\n`, ), }); + surfaceMcpSkipped(deps.io, resumed.mcpSkipped); built = resumed; // Seed the view header: a resumed session never re-emits `session:started`, so without this the footer // would show no model and zero cost/turns until the first new turn (the durable record is unaffected). @@ -359,11 +364,27 @@ async function runReplLoop(wiring: ReplWiring, deps: ChatReplDeps): Promise = {}): McpClient { + return { + capability: { call: () => Promise.resolve({ content: [], isError: false }) }, + toolDefs: [], + skipped: [], + close: () => Promise.resolve(), + ...overrides, + }; +} + +const stdioRef = (over: Partial = {}): McpServerRef => ({ + id: 'fs', + transport: 'stdio', + command: 'my-server', + ...over, +}); + +describe('resolveStdioServerConfigs', () => { + it('maps a stdio ref to a config carrying its id + allowlist (open is a deferred spawn closure)', () => { + const configs = resolveStdioServerConfigs( + [stdioRef({ tools_allowlist: ['read', 'write'] })], + '/work', + ); + expect(configs).toHaveLength(1); + expect(configs[0]?.id).toBe('fs'); + expect(configs[0]?.toolsAllowlist).toEqual(['read', 'write']); + expect(typeof configs[0]?.open).toBe('function'); // not invoked here — no spawn in a unit test + }); + + it('omits toolsAllowlist when the ref declares none (exactOptionalPropertyTypes — never an explicit undefined)', () => { + const configs = resolveStdioServerConfigs([stdioRef()], '/work'); + expect('toolsAllowlist' in configs[0]!).toBe(false); + }); + + it('returns an empty list for undefined / empty mcp_servers', () => { + expect(resolveStdioServerConfigs(undefined, '/work')).toEqual([]); + expect(resolveStdioServerConfigs([], '/work')).toEqual([]); + }); + + it('rejects a network transport as a typed exit-2 CliError (stdio only until the Step-4 follow-up)', () => { + // `sse`/`websocket` are valid schema transports but not yet wired — fail loud, never a silent skip. + for (const transport of ['sse', 'websocket'] as const) { + try { + resolveStdioServerConfigs([{ id: 'x', transport, url: 'https://h/mcp' }], '/work'); + expect.unreachable('a network transport must throw'); + } catch (err) { + expect(isCliError(err) && err.code).toBe('invalid_invocation'); + expect((err as Error).message).toContain(transport); + } + } + }); + + it('rejects a stdio ref with no command (defensive — the schema guarantees it, but the spawn must be total)', () => { + // Construct the ref directly (bypassing the schema superRefine) to exercise the host-side guard. + const bad: McpServerRef = { id: 'fs', transport: 'stdio' }; + expect(() => resolveStdioServerConfigs([bad], '/work')).toThrow(/requires a 'command'/); + }); + + it('rejects an env value carrying a {{…}} marker (secret interpolation is a Step-4 follow-up)', () => { + try { + resolveStdioServerConfigs([stdioRef({ env: { TOKEN: '{{secrets.gh}}' } })], '/work'); + expect.unreachable('a {{…}} env value must throw'); + } catch (err) { + expect(isCliError(err) && err.code).toBe('invalid_invocation'); + // The error names the KEY, never the value — a placeholder is not a secret, but stay disciplined. + expect((err as Error).message).toContain('TOKEN'); + expect((err as Error).message).not.toContain('secrets.gh'); + } + }); + + it('accepts a literal env value (the common case)', () => { + expect(() => + resolveStdioServerConfigs([stdioRef({ env: { LOG_LEVEL: 'debug' } })], '/work'), + ).not.toThrow(); + }); +}); + +describe('connectAgentMcp', () => { + it('returns undefined when the agent declares no servers (no client, nothing to tear down)', async () => { + const client = await connectAgentMcp(undefined, { cwd: '/work' }); + expect(client).toBeUndefined(); + }); + + it('starts the resolved stdio configs via the injected starter and returns the live client', async () => { + let seen: readonly McpServerConfig[] | undefined; + const expected = fakeClient({ + skipped: [{ server: 'fs', name: 'bad', reason: 'unsupported' }], + }); + const client = await connectAgentMcp([stdioRef()], { + cwd: '/work', + startMcpClient: (servers) => { + seen = servers; + return Promise.resolve(expected); + }, + }); + expect(seen?.[0]?.id).toBe('fs'); // the resolver's config reached the starter + expect(client).toBe(expected); + }); + + it('wraps an McpError connect failure as a typed CliError with the secret-free message, no cause', async () => { + const promise = connectAgentMcp([stdioRef()], { + cwd: '/work', + startMcpClient: () => Promise.reject(new McpError('spawn failed for "fs"')), + }); + await expect(promise).rejects.toMatchObject({ code: 'invalid_invocation' }); + await promise.catch((err: unknown) => { + expect((err as Error).message).toContain('spawn failed for "fs"'); + expect((err as Error).cause).toBeUndefined(); // the opaque cause chain is never attached + }); + }); + + it('rethrows a non-McpError failure unchanged (an unexpected fault is not masked as invalid_invocation)', async () => { + const boom = new TypeError('unexpected'); + await expect( + connectAgentMcp([stdioRef()], { cwd: '/work', startMcpClient: () => Promise.reject(boom) }), + ).rejects.toBe(boom); + }); +}); diff --git a/apps/cli/src/engine/mcp-servers.ts b/apps/cli/src/engine/mcp-servers.ts new file mode 100644 index 00000000..723ae4ef --- /dev/null +++ b/apps/cli/src/engine/mcp-servers.ts @@ -0,0 +1,122 @@ +import { + McpError, + openStdioConnection, + startMcpClient as defaultStartMcpClient, + type McpClient, + type McpServerConfig, +} from '@relavium/mcp'; +import type { McpServerRef } from '@relavium/shared'; + +import { CliError } from '../process/errors.js'; + +/** + * Resolve an agent's inline `mcp_servers` into a live {@link McpClient} (2.R Step 3 — CLI host wiring). This is + * the Node-host arm that ADR-0052 §2 delegates to the host: it turns each declared **stdio** server into an + * {@link McpServerConfig} whose `open()` spawns + connects via `@relavium/mcp`'s SDK-fenced `openStdioConnection`, + * then hands the set to `startMcpClient` (fail-loud connect-all). Only Relavium shapes cross back — the SDK and + * `node:child_process` stay fenced inside `@relavium/mcp`, and `packages/core` never sees either. + * + * **Stdio only for now.** A `sse`/`websocket` (network) server fails loud here — the network transports + their + * SSRF guard are the Step-4 follow-up ([ADR-0053](../../../docs/decisions/0053-mcp-network-transport-egress-security.md)), + * and silently dropping a declared server is the opposite of secure-by-default. **No secret interpolation yet.** + * `{{secrets.*}}` resolution into the child env is also Step 4 (ADR-0052 §6); until it lands an `env` value + * containing `{{` is **rejected loud** so a placeholder is never passed to the server as a literal string. + */ + +/** Options for {@link connectAgentMcp} — the spawn working dir + an injectable client starter (tests). */ +export interface ConnectAgentMcpOptions { + /** The session/run working directory — the spawned server's `cwd` (relative server paths resolve here). */ + readonly cwd: string; + /** Injectable connect-all (tests pass a fake that never spawns); defaults to the real `startMcpClient`. */ + readonly startMcpClient?: (servers: readonly McpServerConfig[]) => Promise; +} + +/** + * Map an agent's inline `mcp_servers` to {@link McpServerConfig}s (stdio only). Throws a typed, exit-2 + * {@link CliError} for a not-yet-wired transport or an unsupported (`{{…}}`) env value — never a silent skip. + */ +export function resolveStdioServerConfigs( + mcpServers: readonly McpServerRef[] | undefined, + cwd: string, +): McpServerConfig[] { + const configs: McpServerConfig[] = []; + for (const ref of mcpServers ?? []) { + if (ref.transport !== 'stdio') { + throw new CliError( + 'invalid_invocation', + `MCP server '${ref.id}': the '${ref.transport}' transport is not wired yet (stdio only for now). ` + + `Network MCP transports land in a follow-up.`, + ); + } + // The schema's `superRefine` already guarantees `command` for a stdio transport; re-assert so the spawn + // spec is total without a non-null assertion (a defensive, typed failure rather than an undefined spawn). + if (ref.command === undefined) { + throw new CliError( + 'invalid_invocation', + `MCP server '${ref.id}': a 'stdio' transport requires a 'command'.`, + ); + } + const command = ref.command; + const env = buildChildEnv(ref.id, ref.env); + configs.push({ + id: ref.id, + ...(ref.tools_allowlist === undefined ? {} : { toolsAllowlist: ref.tools_allowlist }), + open: () => + openStdioConnection(ref.id, { + command, + env, + cwd, + ...(ref.args === undefined ? {} : { args: ref.args }), + }), + }); + } + return configs; +} + +/** + * Connect an agent's inline `mcp_servers` and return the live {@link McpClient}, or `undefined` when the agent + * declares none (so the caller wires no MCP and has nothing to tear down). A connect/`tools/list` failure is + * **fail-loud**: it surfaces as a typed, exit-2 {@link CliError} whose message is the secret-free MCP summary — + * the opaque `cause` chain is intentionally NOT attached, honoring the host-boundary cause-strip obligation + * ([ADR-0052](../../../docs/decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §2 / errors.ts). + */ +export async function connectAgentMcp( + mcpServers: readonly McpServerRef[] | undefined, + opts: ConnectAgentMcpOptions, +): Promise { + const configs = resolveStdioServerConfigs(mcpServers, opts.cwd); + if (configs.length === 0) return undefined; + const start = opts.startMcpClient ?? defaultStartMcpClient; + try { + return await start(configs); + } catch (err) { + if (err instanceof McpError) { + // The MCP error `message` is secret-free by contract; the opaque `cause` is dropped (never surfaced). + throw new CliError('invalid_invocation', `MCP server connection failed: ${err.message}`); + } + throw err; + } +} + +/** + * Build the child env for a stdio server from its declared `env` (verbatim for now). Rejects any value carrying + * a `{{…}}` interpolation marker: `{{secrets.*}}` resolution is the Step-4 follow-up (ADR-0052 §6), and passing + * an unresolved placeholder to the server as a literal is a silent-misconfig footgun, so it fails loud instead. + */ +function buildChildEnv( + serverId: string, + declared: Readonly> | undefined, +): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(declared ?? {})) { + if (value.includes('{{')) { + throw new CliError( + 'invalid_invocation', + `MCP server '${serverId}': env interpolation (e.g. {{secrets.…}}) in '${key}' is not wired yet. ` + + `Set a literal value for now, or omit it.`, + ); + } + env[key] = value; + } + return env; +} diff --git a/apps/cli/tsup.config.ts b/apps/cli/tsup.config.ts index 2f5dd21f..7ae1c354 100644 --- a/apps/cli/tsup.config.ts +++ b/apps/cli/tsup.config.ts @@ -26,6 +26,11 @@ const THIRD_PARTY_EXTERNAL = [ '@clack/prompts', '@google/genai', '@jitl/quickjs-singlefile-mjs-release-sync', + // The MCP SDK is a vendor SDK like the others — externalize it (root + every subpath the stdio adapter + // imports, e.g. `…/client/stdio.js`) so it and its own transitive deps install via npm, never inlined. + // `@relavium/mcp` (inlined) is the only importer; the bundle then carries just `@modelcontextprotocol/sdk`. + '@modelcontextprotocol/sdk', + '@modelcontextprotocol/sdk/*', '@napi-rs/keyring', 'better-sqlite3', 'commander', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ffbcfad3..86ccf8f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,9 @@ importers: '@jitl/quickjs-singlefile-mjs-release-sync': specifier: 'catalog:' version: 0.32.0 + '@modelcontextprotocol/sdk': + specifier: 'catalog:' + version: 1.29.0(zod@3.25.76) '@napi-rs/keyring': specifier: 'catalog:' version: 1.3.0 @@ -189,6 +192,9 @@ importers: '@relavium/llm': specifier: workspace:* version: link:../../packages/llm + '@relavium/mcp': + specifier: workspace:* + version: link:../../packages/mcp '@relavium/shared': specifier: workspace:* version: link:../../packages/shared From d350f692bef07cd1481d55c185e76f50ed96e85b Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 27 Jun 2026 09:45:28 +0300 Subject: [PATCH 02/24] =?UTF-8?q?fix(cli):=202.R=20Step=203a=20Opus-review?= =?UTF-8?q?=20=E2=80=94=20sanitize=20MCP=20skip=20notes,=20self-cleaning?= =?UTF-8?q?=20MCP=20teardown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two confirmed findings from the adversarial multi-agent review of d578cb9: - [MEDIUM] `surfaceMcpSkipped` wrote the server-controlled tool `name` + `reason` (in-threat-model untrusted, ADR-0052 §4) raw to the TTY — a terminal-escape-injection sink the rest of the surface already guards. Run all interpolated fields through `sanitizeInline` (the same strip the resume banner / slash echo / streamed tokens use). + a control-byte test. - [LOW] MCP connections could leak if a step threw between connect and the protected `finally` (ADR-0052 §2 teardown-on-terminal). Fixed at the root: `buildChatSession`/`buildResumedChatSession` are now self-cleaning (a post-connect construction fault closes the client before propagating), and the command build→loop windows tear MCP down on a pre-loop fault — `chatCommand` guards `openSessionStore`, `chatResumeCommand`'s catch closes MCP alongside the db, and `agent run` moves its post-build wiring inside the protected try. + a self-cleaning-build test (a duplicate-id registry build closes the client). lint/typecheck/test (625)/build + format all green. Refs: ADR-0052 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/chat/session-host.test.ts | 30 ++++++- apps/cli/src/chat/session-host.ts | 112 ++++++++++++++----------- apps/cli/src/commands/agent-run.ts | 20 +++-- apps/cli/src/commands/chat.test.ts | 17 ++++ apps/cli/src/commands/chat.ts | 50 +++++++---- 5 files changed, 156 insertions(+), 73 deletions(-) diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index 8bf31627..23f85840 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -4,7 +4,12 @@ import { join } from 'node:path'; import { BudgetExceededError, BudgetPauseError } from '@relavium/core'; import type { SessionStreamHandleEvent } from '@relavium/core'; -import { startMcpClient as realStartMcpClient, type McpConnection } from '@relavium/mcp'; +import { + buildServerToolDefs, + startMcpClient as realStartMcpClient, + type McpClient, + type McpConnection, +} from '@relavium/mcp'; import type { AgentSessionRecord, SessionMessage } from '@relavium/shared'; import { describe, expect, it } from 'vitest'; @@ -235,6 +240,29 @@ describe('buildChatSession + MCP host wiring (2.R)', () => { expect(built.mcpSkipped.map((s) => s.name)).toContain('danger'); // excluded by the allowlist await built.closeMcp?.(); }); + + it('self-cleans: a post-connect construction fault tears the just-connected client down (no leak)', async () => { + // Force a post-connect throw: two ToolDefs sharing an id make `createToolRegistry` reject ("duplicate tool + // id") AFTER the client is connected. The build must close the client before the failure propagates, so a + // setup fault can never orphan a spawned MCP child. + const { defs } = buildServerToolDefs('fs', [{ name: 'read', inputSchema: { type: 'object' } }]); + let closed = 0; + const collidingClient: McpClient = { + capability: { call: () => Promise.resolve({ content: [], isError: false }) }, + toolDefs: [...defs, ...defs], // duplicate id ⇒ createToolRegistry throws inside buildSessionRuntime + skipped: [], + close: () => { + closed += 1; + return Promise.resolve(); + }, + }; + const building = build({ + agentRef: writeMcpAgent(), + startMcpClient: () => Promise.resolve(collidingClient), + }); + await expect(building).rejects.toThrow(/duplicate tool id/); + expect(closed).toBe(1); // the build closed the client it had just opened + }); }); describe('buildResumedChatSession (2.N)', () => { diff --git a/apps/cli/src/chat/session-host.ts b/apps/cli/src/chat/session-host.ts index 07319b75..70fa701a 100644 --- a/apps/cli/src/chat/session-host.ts +++ b/apps/cli/src/chat/session-host.ts @@ -188,27 +188,35 @@ export async function buildChatSession(opts: BuildChatSessionOptions): Promise session.cancel()); - return { - session, - handle, - sessionId, - agent, - context, - emitSessionEvent: emit, - mcpSkipped: mcp?.skipped ?? [], - ...(mcp === undefined ? {} : { closeMcp: () => mcp.close() }), - }; + try { + const { bus, deps, emit } = buildSessionRuntime(opts, sessionId, mcp); + // The session runs against the EFFECTIVE agent (its grant unioned with the discovered MCP tool ids); the + // ORIGINAL `agent` is what we return + persist (see {@link BuiltChatSession.agent}). + const session = new AgentSession({ + sessionId, + agentRef: agent.id, + agent: withMcpGrant(agent, mcp), + context, + deps, + }); + const handle = createSessionHandle(bus, sessionId, () => session.cancel()); + return { + session, + handle, + sessionId, + agent, + context, + emitSessionEvent: emit, + mcpSkipped: mcp?.skipped ?? [], + ...(mcp === undefined ? {} : { closeMcp: () => mcp.close() }), + }; + } catch (err) { + // Self-clean: a post-connect construction fault (e.g. a duplicate-id `createToolRegistry` build) must not + // leak the just-spawned MCP children — tear them down before the failure propagates. The build is then + // all-or-nothing: it either returns a session that OWNS `closeMcp`, or it has already closed the client. + await mcp?.close(); + throw err; + } } /** @@ -290,36 +298,42 @@ export async function buildResumedChatSession( ...(opts.startMcpClient === undefined ? {} : { startMcpClient: opts.startMcpClient }), }); - const { bus, deps, emit } = buildSessionRuntime(opts, record.id, mcp); - const session = AgentSession.resume( - { + try { + const { bus, deps, emit } = buildSessionRuntime(opts, record.id, mcp); + const session = AgentSession.resume( + { + sessionId: record.id, + agentRef: agent.id, + agent: withMcpGrant(agent, mcp), + context, + deps, + }, + resumeState, + ); + const handle = createSessionHandle(bus, record.id, () => session.cancel()); + // Seed the persister one past the persisted MAX(sequence_number) — a fold (not `Math.max(...spread)`, which + // would overflow the argument-count limit on a very long transcript) over the durable rows, so it is + // order-independent and starts an empty transcript at 0 (reduce of `[]` from -1, +1 = 0). NOTE: this is a + // single-writer assumption — the next seq is read at load time, so two concurrent resumes of the SAME + // session would collide on the `(session_id, sequence_number)` UNIQUE index (a loud failure, not corruption). + const nextSequenceNumber = messages.reduce((max, m) => Math.max(max, m.sequenceNumber), -1) + 1; + return { + session, + handle, sessionId: record.id, - agentRef: agent.id, - agent: withMcpGrant(agent, mcp), + agent, context, - deps, - }, - resumeState, - ); - const handle = createSessionHandle(bus, record.id, () => session.cancel()); - // Seed the persister one past the persisted MAX(sequence_number) — a fold (not `Math.max(...spread)`, which - // would overflow the argument-count limit on a very long transcript) over the durable rows, so it is - // order-independent and starts an empty transcript at 0 (reduce of `[]` from -1, +1 = 0). NOTE: this is a - // single-writer assumption — the next seq is read at load time, so two concurrent resumes of the SAME - // session would collide on the `(session_id, sequence_number)` UNIQUE index (a loud failure, not corruption). - const nextSequenceNumber = messages.reduce((max, m) => Math.max(max, m.sequenceNumber), -1) + 1; - return { - session, - handle, - sessionId: record.id, - agent, - context, - emitSessionEvent: emit, - resumeState, - nextSequenceNumber, - mcpSkipped: mcp?.skipped ?? [], - ...(mcp === undefined ? {} : { closeMcp: () => mcp.close() }), - }; + emitSessionEvent: emit, + resumeState, + nextSequenceNumber, + mcpSkipped: mcp?.skipped ?? [], + ...(mcp === undefined ? {} : { closeMcp: () => mcp.close() }), + }; + } catch (err) { + // Self-clean: a post-connect fault must not leak the just-spawned MCP children (see {@link buildChatSession}). + await mcp?.close(); + throw err; + } } export interface GovernorWiring { diff --git a/apps/cli/src/commands/agent-run.ts b/apps/cli/src/commands/agent-run.ts index 2efd2f00..03cc6f2b 100644 --- a/apps/cli/src/commands/agent-run.ts +++ b/apps/cli/src/commands/agent-run.ts @@ -93,22 +93,24 @@ export async function agentRunCommand( uuid, providers, }); - surfaceMcpSkipped(deps.io, built.mcpSkipped); - // Render the live stream (NDJSON under --json, else the plain token/tool printer) and capture the turn // outcome — a classified turn failure completes with `session:turn_completed.error`, mapping to exit 1. let turnErrorCode: string | undefined; const renderer: (event: SessionStreamHandleEvent) => void = deps.global.json ? (event) => deps.io.writeOut(`${JSON.stringify(event)}\n`) : makePlainPrinter(deps.io); - const unsubscribe = built.handle.subscribe((event) => { - renderer(event); - if (event.type === 'session:turn_completed' && event.error !== undefined) { - turnErrorCode = event.error.code; - } - }); - + // The session OWNS the live MCP connections (built.closeMcp); the finally tears them down. Keep the whole + // post-build region (skipped-tool note + subscribe) INSIDE the try so any fault there still hits the finally + // rather than orphaning the spawned children (a no-op unsubscribe until the real one is wired below). + let unsubscribe: () => void = () => {}; try { + surfaceMcpSkipped(deps.io, built.mcpSkipped); + unsubscribe = built.handle.subscribe((event) => { + renderer(event); + if (event.type === 'session:turn_completed' && event.error !== undefined) { + turnErrorCode = event.error.code; + } + }); built.session.start(); await built.session.sendMessage(message); } catch (err) { diff --git a/apps/cli/src/commands/chat.test.ts b/apps/cli/src/commands/chat.test.ts index 8b4ec4c2..a8671d2d 100644 --- a/apps/cli/src/commands/chat.test.ts +++ b/apps/cli/src/commands/chat.test.ts @@ -926,4 +926,21 @@ describe('surfaceMcpSkipped (2.R)', () => { expect(lines[1]).toContain("MCP tool 'bad-id!' (server 'gh')"); expect(out()).toBe(''); // diagnostics stay on stderr — stdout (the --json stream) is untouched }); + + it('sanitizes a hostile server-controlled tool name + reason (no terminal-escape injection reaches the TTY)', () => { + // `name`/`reason` are server-controlled and the MCP server is in-threat-model untrusted (ADR-0052 §4): a + // crafted tool returning ANSI/OSC control bytes must NOT write them raw to the operator's terminal. + const { io, err } = captureIo(); + surfaceMcpSkipped(io, [ + { + server: 'fs', + name: 'evil\x1b[2J\x1b]0;pwned\x07', + reason: 'bad\x1b[31m schema\x1b[0m', + }, + ]); + const out = err(); + // eslint-disable-next-line no-control-regex -- asserting the absence of control bytes is the point + expect(/[\x00-\x1f\x7f]/.test(out.replace(/\n$/, ''))).toBe(false); // no control bytes survived (besides the trailing \n) + expect(out).not.toContain('\x1b'); // the ESC that opens every escape sequence is gone + }); }); diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index 79b9891f..f3f27720 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -147,18 +147,27 @@ export async function chatCommand(args: ChatCommandArgs, deps: ChatCommandDeps): `budget warning: ~${warning.thresholdPct}% of the ${warning.limitMicrocents}µ¢ cap reached\n`, ), }); - surfaceMcpSkipped(deps.io, built.mcpSkipped); - - const opened = (deps.openSessionStore ?? openSessionStore)(homeDir); - const persister = createSessionPersister({ - store: opened.store, - handle: built.handle, - sessionId: built.sessionId, - agent: built.agent, - context: built.context, - now, - uuid, - }); + // The session now OWNS the live MCP connections (built.closeMcp). `runReplLoop`'s finally is the steady-state + // teardown, but the build→loop window (opening history.db can throw) runs first — guard it so a pre-loop fault + // tears the connections down rather than orphaning the spawned children (ADR-0052 §2 teardown-on-terminal). + let opened: OpenedSessionStore; + let persister: SessionPersister; + try { + surfaceMcpSkipped(deps.io, built.mcpSkipped); + opened = (deps.openSessionStore ?? openSessionStore)(homeDir); + persister = createSessionPersister({ + store: opened.store, + handle: built.handle, + sessionId: built.sessionId, + agent: built.agent, + context: built.context, + now, + uuid, + }); + } catch (err) { + await built.closeMcp?.(); + throw err; + } return runReplLoop( { built, opened, store, persister, startSession: () => built.session.start() }, @@ -192,6 +201,10 @@ export async function chatResumeCommand( let store: ChatStoreController; let persister: SessionPersister; let intro: string; + // The just-built resumed session OWNS its MCP connections; if a pre-loop step after a SUCCESSFUL build throws, + // the catch must tear them down (the steady-state teardown is runReplLoop's finally, not yet entered). Undefined + // when no server was declared OR the build self-cleaned its own post-connect fault (see buildResumedChatSession). + let closeMcp: (() => Promise) | undefined; try { // The current `[chat]` config governs the resumed turn/cost caps; the agent, model, and context are the // frozen originals from the record. An absent session is a clean exit-2 invocation fault. @@ -210,6 +223,7 @@ export async function chatResumeCommand( `budget warning: ~${warning.thresholdPct}% of the ${warning.limitMicrocents}µ¢ cap reached\n`, ), }); + closeMcp = resumed.closeMcp; surfaceMcpSkipped(deps.io, resumed.mcpSkipped); built = resumed; // Seed the view header: a resumed session never re-emits `session:started`, so without this the footer @@ -249,7 +263,9 @@ export async function chatResumeCommand( ); } } catch (err) { - // A pre-loop fault (not-found, no snapshot, build failure) must not strand the open db handle. + // A pre-loop fault (not-found, no snapshot, build failure, or a post-build setup throw) must not strand the + // open db handle NOR the spawned MCP children — tear both down (closeMcp is idempotent + a no-op when unset). + await closeMcp?.(); opened.close(); throw err; } @@ -376,11 +392,17 @@ async function runReplLoop(wiring: ReplWiring, deps: ChatReplDeps): Promise Date: Sat, 27 Jun 2026 10:00:34 +0300 Subject: [PATCH 03/24] =?UTF-8?q?test(cli,docs):=202.R=20Step=203a=20Sonne?= =?UTF-8?q?t-review=20=E2=80=94=20resume=20+=20agent-run=20MCP=20coverage,?= =?UTF-8?q?=20=C2=A75=20drift=20tracked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three confirmed findings from the second-model adversarial review of d350f69 — all test-coverage / doc-tracking gaps (no production defect): - [MEDIUM] The resume-path MCP wiring (re-discover-on-resume — the ADR-0052 §3 persistence promise: snapshot the ORIGINAL agent, re-augment the grant fresh) was untested; the fresh path duplicates the assembly. Add a resume test: the discovered tool routes (proving withMcpGrant ran on the snapshot agent), the returned agent stays original (mcp_servers, no baked ids), teardown closes; + a resume self-clean variant. - [LOW] agent-run's own command-level MCP wiring (surfaceMcpSkipped + closeMcp in finally) was untested. Add a one-shot test over the real buildChatSession + a fake connection: the dropped tool note lands on stderr (not the --json stdout), the connection is torn down once. - [LOW] ADR-0052 §5 transport-vocab reconciliation (stdio|http|websocket, sse→http alias) is a sibling network sub-step, not Step 3a — but it was untracked, leaving the spec silently ahead of the agent schema. Record it in deferred-tasks.md. The other 4 raw findings were correctly refuted (a defense-in-depth seenIds note, the verified-correct cause-strip, test-realism nits). lint/typecheck/test (628) + format green. Refs: ADR-0052 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/chat/session-host.test.ts | 76 +++++++++++++++++++++++++ apps/cli/src/commands/agent-run.test.ts | 42 ++++++++++++++ docs/roadmap/deferred-tasks.md | 1 + 3 files changed, 119 insertions(+) diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index 23f85840..3dddb0fd 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -408,6 +408,82 @@ describe('buildResumedChatSession (2.N)', () => { /no stored agent snapshot/, ); }); + + describe('MCP re-discovery on resume (2.R)', () => { + // The snapshot stores the AUTHOR's agent (mcp_servers, NOT the dynamic ids); resume re-discovers fresh. + const mcpSnapshot = { + ...RESUME_AGENT, + mcp_servers: [{ id: 'fs', transport: 'stdio' as const, command: 'my-server' }], + }; + + it('re-discovers the snapshot mcp_servers: original grant returned, the discovered tool routes, teardown closes', async () => { + const calls: { name: string; args: unknown }[] = []; + let closed = 0; + const conn: McpConnection = { + listTools: () => Promise.resolve([{ name: 'read', inputSchema: { type: 'object' } }]), + callTool: (name, args) => { + calls.push({ name, args }); + return Promise.resolve({ content: [{ type: 'text', text: 'ok' }], isError: false }); + }, + close: () => { + closed += 1; + return Promise.resolve(); + }, + }; + const built = await buildResumedChatSession({ + chat: EMPTY_CHAT, + record: record({ agentSnapshot: mcpSnapshot }), + messages: [message(0, 'user', 'hi'), message(1, 'assistant', 'hello')], + now: () => Date.parse(ISO), + providers: scriptedResolver([toolUseTurn('c1', 'mcp_fs_read'), textTurn('done')]), + startMcpClient: () => realStartMcpClient([{ id: 'fs', open: () => Promise.resolve(conn) }]), + }); + + // The RETURNED agent is the ORIGINAL snapshot — its grant is not baked with the dynamic id, and it still + // carries mcp_servers so a FUTURE resume re-discovers again (the persistence contract). + expect(built.agent.tools).toEqual(mcpSnapshot.tools); + expect(built.agent.mcp_servers).toEqual(mcpSnapshot.mcp_servers); + expect(typeof built.closeMcp).toBe('function'); + + // A resumed session is already idle — no start(); sendMessage continues. The call routing PROVES + // withMcpGrant ran on the snapshot agent (else the call would be denied not_granted). + await built.session.sendMessage('go'); + built.session.cancel(); + const events = await drainHandle(built.handle.events); + const toolCall = events.find((e) => e.type === 'agent:tool_call'); + expect(toolCall?.type === 'agent:tool_call' && toolCall.toolId).toBe('mcp_fs_read'); + expect(calls).toEqual([{ name: 'read', args: {} }]); + + await built.closeMcp?.(); + expect(closed).toBe(1); + }); + + it('self-cleans on resume: a post-connect construction fault tears the just-connected client down', async () => { + const { defs } = buildServerToolDefs('fs', [ + { name: 'read', inputSchema: { type: 'object' } }, + ]); + let closed = 0; + const collidingClient: McpClient = { + capability: { call: () => Promise.resolve({ content: [], isError: false }) }, + toolDefs: [...defs, ...defs], // duplicate id ⇒ createToolRegistry throws post-connect + skipped: [], + close: () => { + closed += 1; + return Promise.resolve(); + }, + }; + const building = buildResumedChatSession({ + chat: EMPTY_CHAT, + record: record({ agentSnapshot: mcpSnapshot }), + messages: [message(0, 'user', 'hi'), message(1, 'assistant', 'hello')], + now: () => Date.parse(ISO), + providers: scriptedResolver([textTurn('unused')]), + startMcpClient: () => Promise.resolve(collidingClient), + }); + await expect(building).rejects.toThrow(/duplicate tool id/); + expect(closed).toBe(1); + }); + }); }); describe('buildGovernorWiring', () => { diff --git a/apps/cli/src/commands/agent-run.test.ts b/apps/cli/src/commands/agent-run.test.ts index 8d056d01..a4119331 100644 --- a/apps/cli/src/commands/agent-run.test.ts +++ b/apps/cli/src/commands/agent-run.test.ts @@ -5,6 +5,9 @@ import { Readable } from 'node:stream'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { startMcpClient as realStartMcpClient, type McpConnection } from '@relavium/mcp'; + +import { buildChatSession } from '../chat/session-host.js'; import { scriptedResolver, textTurn, unresolvedResolver } from '../chat/test-support.js'; import type { ProviderResolver } from '../engine/providers.js'; import { isCliError } from '../process/errors.js'; @@ -84,6 +87,45 @@ describe('agentRunCommand (2.Q)', () => { expect(out()).toContain('the summary'); }); + it('an MCP-declaring agent: surfaces dropped tools to stderr and tears the connection down after the turn (2.R)', async () => { + // The one-shot's OWN command-level MCP wiring: surfaceMcpSkipped (→ stderr, not the --json stdout) + the + // closeMcp teardown in the finally. Drives the REAL buildChatSession over a fake connection (no spawn). + writeFileSync( + join(cwd, 'mcp.agent.yaml'), + `${AGENT_YAML}\nmcp_servers:\n - id: fs\n transport: stdio\n command: x`, + ); + let closed = 0; + const conn: McpConnection = { + listTools: () => + Promise.resolve([ + { name: 'read', inputSchema: { type: 'object' } }, + { name: 'danger', inputSchema: { type: 'object' } }, + ]), + callTool: () => Promise.resolve({ content: [], isError: false }), + close: () => { + closed += 1; + return Promise.resolve(); + }, + }; + const { d, out, err } = deps('go', { providers: scriptedResolver([textTurn('done')]) }); + const buildSession: typeof buildChatSession = (o) => + buildChatSession({ + ...o, + startMcpClient: () => + realStartMcpClient([ + { id: 'fs', toolsAllowlist: ['read'], open: () => Promise.resolve(conn) }, + ]), + }); + const code = await agentRunCommand( + { agent: join(cwd, 'mcp.agent.yaml'), input: [] }, + { ...d, buildSession }, + ); + expect(code).toBe(EXIT_CODES.success); + expect(err()).toContain("MCP tool 'danger'"); // the allowlist-dropped tool note went to stderr + expect(out()).not.toContain('danger'); // …never to stdout (the --json stream stays pure) + expect(closed).toBe(1); // the connection was torn down once, after the turn (finally) + }); + it('emits a pure NDJSON session stream under --json (no human chrome, no key leak)', async () => { const { d, out, err } = deps('hi', { json: true, diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 2504231d..12b653a2 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -44,6 +44,7 @@ Severity is the review's verified rating. Check an item off in the PR that resol - [ ] **MCP SDK network transport — upgrade to connect-by-validated-IP ([ADR-0053](../decisions/0053-mcp-network-transport-egress-security.md) §2).** 2.R ships **pre-connect host validation** as the floor for the `http` (Streamable HTTP) / `websocket` MCP transports — the `@modelcontextprotocol/sdk` opens its **own** socket, architecturally distinct from the `EgressCapability.fetch` hook above. When the SDK transport exposes an injectable `fetch`/dialer hook, upgrade to **connect-by-validated-IP**: resolve DNS → validate the IP against the shared range-block primitive → connect to that IP, re-validating on each redirect hop — closing the residual DNS-rebind window. Each MCP network mechanism gets a dedicated security-review pass when it lands. *(packages/mcp/src; ADR-0053 §2; ADR-0043 mechanism)* - [ ] **MCP `stdio` spawn — import-trust/consent gate + `npx` dependency pinning ([ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §2).** Spawning a declared `stdio` MCP server runs arbitrary local code / an `npx`-installed package. 2.R treats a server declared in the user's **own** committed YAML as author trust; the **imported/shared untrusted workflow** case is out of baseline scope. When the import/share path matures, gate the first spawn of a server from an untrusted-provenance `.relavium.yaml` behind explicit consent, and pin the `npx` package version/integrity for the built-in auto-install servers. *(packages/mcp/src; apps/cli; ADR-0052 §2; ADR-0029 trust model)* - [ ] **MCP host boundary — strip `McpConnectError.cause` from `--json` / event output (2.R Step 3, [ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §2).** The typed MCP errors' `message` is secret-free + surfaceable, but `.cause` (the wrapped SDK/spawn error) MAY carry the spawned command/args (never env/secret data). When the CLI host wires `@relavium/mcp` (Step 3+), a connect/discovery failure must surface as a typed `CliError` whose message is safe, and the host MUST NOT serialize `cause` verbatim into a `--json` envelope or a `RunEvent` — strip it at that boundary. The contract is documented in `@relavium/mcp` `errors.ts`. *(apps/cli; packages/mcp/src/errors.ts; 2.R Step 3)* +- [ ] **MCP transport-vocabulary reconciliation (`stdio | http | websocket`) ([ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §5).** ADR-0052 §5 binds both schemas to one vocabulary — `McpServerRefSchema` (agent) `stdio | sse | websocket` → `stdio | http | websocket` with `sse` a **deprecated alias** of `http`; `McpServerRegistrationSchema` (config) is already `stdio | http` — and updates `agent-yaml-spec.md`, `config-spec.md`, `mcp-integration.md` (which still document the legacy `stdio | sse | websocket`) to match. **2.R Step 3a wired only `stdio` (the CLI fails loud on any non-stdio transport), so the canonical spec is currently ahead of the agent schema.** This reconciliation lands with the network-transport sub-step (the by-name `ref` + `http`/`websocket` adapters), NOT the stdio host-wiring step — recorded here so the spec↔schema drift is tracked, not silent. *(packages/shared/src/agent.ts + config.ts; docs/reference/contracts/{agent-yaml-spec,config-spec}.md; docs/reference/shared-core/mcp-integration.md; ADR-0052 §5)* - [ ] **MCP follow-ups (non-security).** A durable cross-invocation **tool-list cache** (mcp-integration.md ~1h per-`(command,args)`, with a transport-covering key) — 2.R re-runs discovery per process ([ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §3); and a generalized **`SecretResolver`** seam beyond the 2.R `mcp-secret:*` keychain namespace ([ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §6); and reconciling the `types.ts` `ToolId` "register dynamically" comment to "host-side assembled" when 2.R touches `packages/core`; and **mid-call abort propagation** — the engine's `AbortSignalLike` is not forwarded to the in-flight MCP `tools/call` (the SDK transport wants a DOM `AbortSignal`), so a turn cancel tears the connection down but does not cancel an in-flight call (`@relavium/mcp` `manager.ts`). *(packages/mcp/src; packages/core; Phase-3)* - [ ] **Streaming media triad (`media_start`/`media_delta`/`media_end`) — host-deferred ([ADR-0046](../decisions/0046-inline-media-out-via-generate-streaming-triad-deferred.md) §4).** 1.AG Section B delivers inline media-out through the non-streaming `generate()` path (the in-flight From cfad5cd689d090ec9c99338977e2d8846f04a209 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 27 Jun 2026 10:18:44 +0300 Subject: [PATCH 04/24] =?UTF-8?q?feat(cli,mcp):=202.R=20Step=203b=20?= =?UTF-8?q?=E2=80=94=20wire=20inbound=20MCP=20into=20relavium=20run=20(std?= =?UTF-8?q?io,=20inline=20agents)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the 2.R run acceptance: a workflow agent declaring an inline stdio `mcp_servers` round-trips a namespaced tool call via `relavium run`. - `@relavium/mcp` manager: expose `toolIdsByServer` (the granted tool ids grouped per server) so the host augments the RIGHT agent's grant when a workflow has several agents declaring different servers. A declared id always maps (to `[]` when all its tools were dropped). - `engine/mcp-servers.ts`: `connectWorkflowMcp(def, opts)` — aggregate the `mcp_servers` across the workflow's INLINE agents (a `$ref` external agent is not resolved in the CLI run path), dedup by server id (an identical declaration shares one connection; the same id with conflicting settings is a fail-loud CliError), start fail-loud, and rewrite the workflow so each inline agent's grant is unioned with ONLY its own servers' discovered ids (per-agent isolation). Factor the shared `startMcpClientFailLoud` (cause-stripped) used by both the chat and run paths, and RELOCATE `surfaceMcpSkipped` here (its MCP-host home; chat/agent-run/run all import it). - `build-engine.ts`: a `mcp?: { toolDefs, capability }` option composes the discovered ToolDefs into the registry + `AgentRunnerDeps.tools` and wires the `McpCapability` onto `ToolHost.mcp` (host-side static assembly, zero engine change, ADR-0052 §3). - `run.ts`: connect the workflow's MCP before building the engine, run the AUGMENTED workflow, surface dropped tools to stderr, and tear the connections down at the run terminal (the finally), cause stripped. Tests: manager `toolIdsByServer` grouping (incl. the empty-entry case); `connectWorkflowMcp` (no-server, per-agent grant isolation, identical-spec dedup, conflicting-spec fail-loud); the relocated `surfaceMcpSkipped` (incl. the hostile-control-byte sanitization); and the `relavium run` acceptance e2e (real manager + a fake connection: aggregate → augment → register → dispatch-route → teardown). lint/typecheck/test (634 cli + 61 mcp)/build + bundle-closure + seam guards + format all green. Refs: ADR-0034, ADR-0052 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/chat/session-host.test.ts | 2 + apps/cli/src/commands/agent-run.ts | 3 +- apps/cli/src/commands/chat.test.ts | 37 ------- apps/cli/src/commands/chat.ts | 22 +--- apps/cli/src/commands/run.test.ts | 66 ++++++++++++ apps/cli/src/commands/run.ts | 42 +++++++- apps/cli/src/engine/build-engine.ts | 30 ++++-- apps/cli/src/engine/mcp-servers.test.ts | 133 +++++++++++++++++++++++- apps/cli/src/engine/mcp-servers.ts | 133 +++++++++++++++++++++++- packages/mcp/src/manager.test.ts | 26 +++++ packages/mcp/src/manager.ts | 14 ++- 11 files changed, 435 insertions(+), 73 deletions(-) diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index 3dddb0fd..21ef0f2a 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -250,6 +250,7 @@ describe('buildChatSession + MCP host wiring (2.R)', () => { const collidingClient: McpClient = { capability: { call: () => Promise.resolve({ content: [], isError: false }) }, toolDefs: [...defs, ...defs], // duplicate id ⇒ createToolRegistry throws inside buildSessionRuntime + toolIdsByServer: new Map(), skipped: [], close: () => { closed += 1; @@ -466,6 +467,7 @@ describe('buildResumedChatSession (2.N)', () => { const collidingClient: McpClient = { capability: { call: () => Promise.resolve({ content: [], isError: false }) }, toolDefs: [...defs, ...defs], // duplicate id ⇒ createToolRegistry throws post-connect + toolIdsByServer: new Map(), skipped: [], close: () => { closed += 1; diff --git a/apps/cli/src/commands/agent-run.ts b/apps/cli/src/commands/agent-run.ts index 03cc6f2b..0f05f77c 100644 --- a/apps/cli/src/commands/agent-run.ts +++ b/apps/cli/src/commands/agent-run.ts @@ -6,12 +6,13 @@ import type { SessionStreamHandleEvent } from '@relavium/core'; import { cassetteResolver, loadCassette } from '../chat/fixture.js'; import { buildChatSession } from '../chat/session-host.js'; import { loadResolvedConfig } from '../config/load.js'; +import { surfaceMcpSkipped } from '../engine/mcp-servers.js'; import { createProviderResolver, type ProviderResolver } from '../engine/providers.js'; import { CliError } from '../process/errors.js'; import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; -import { makePlainPrinter, surfaceMcpSkipped } from './chat.js'; +import { makePlainPrinter } from './chat.js'; /** * `relavium agent run ` (2.Q) — invoke a single agent **one-shot** (non-interactive) on the same diff --git a/apps/cli/src/commands/chat.test.ts b/apps/cli/src/commands/chat.test.ts index a8671d2d..148eb781 100644 --- a/apps/cli/src/commands/chat.test.ts +++ b/apps/cli/src/commands/chat.test.ts @@ -27,7 +27,6 @@ import { driveJson, drivePlain, makePlainPrinter, - surfaceMcpSkipped, type ChatCommandDeps, type ChatDriveContext, type ChatDriver, @@ -908,39 +907,3 @@ describe('driveJson (2.Q)', () => { expect(process.listeners('SIGINT').filter((l) => !before.includes(l))).toHaveLength(0); // finally removed it }); }); - -describe('surfaceMcpSkipped (2.R)', () => { - it('writes one secret-free stderr note per dropped tool (name + server + reason), nothing on an empty list', () => { - const { io, err, out } = captureIo(); - surfaceMcpSkipped(io, []); - expect(err()).toBe(''); // no MCP servers / nothing dropped ⇒ silent (the common case) - - surfaceMcpSkipped(io, [ - { server: 'fs', name: 'danger', reason: 'not in tools_allowlist' }, - { server: 'gh', name: 'bad-id!', reason: 'unsafe LLM tool name' }, - ]); - const lines = err().trimEnd().split('\n'); - expect(lines).toHaveLength(2); - expect(lines[0]).toContain("MCP tool 'danger' (server 'fs')"); - expect(lines[0]).toContain('not in tools_allowlist'); - expect(lines[1]).toContain("MCP tool 'bad-id!' (server 'gh')"); - expect(out()).toBe(''); // diagnostics stay on stderr — stdout (the --json stream) is untouched - }); - - it('sanitizes a hostile server-controlled tool name + reason (no terminal-escape injection reaches the TTY)', () => { - // `name`/`reason` are server-controlled and the MCP server is in-threat-model untrusted (ADR-0052 §4): a - // crafted tool returning ANSI/OSC control bytes must NOT write them raw to the operator's terminal. - const { io, err } = captureIo(); - surfaceMcpSkipped(io, [ - { - server: 'fs', - name: 'evil\x1b[2J\x1b]0;pwned\x07', - reason: 'bad\x1b[31m schema\x1b[0m', - }, - ]); - const out = err(); - // eslint-disable-next-line no-control-regex -- asserting the absence of control bytes is the point - expect(/[\x00-\x1f\x7f]/.test(out.replace(/\n$/, ''))).toBe(false); // no control bytes survived (besides the trailing \n) - expect(out).not.toContain('\x1b'); // the ESC that opens every escape sequence is gone - }); -}); diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index f3f27720..7ceb83cb 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -6,8 +6,6 @@ import { type SessionHandle, type SessionStreamHandleEvent, } from '@relavium/core'; -import type { ManagerSkippedTool } from '@relavium/mcp'; - import { exportSession } from '../chat/export.js'; import { createSessionPersister, type SessionPersister } from '../chat/persister.js'; import { @@ -16,6 +14,7 @@ import { type BuiltChatSession, } from '../chat/session-host.js'; import { loadResolvedConfig } from '../config/load.js'; +import { surfaceMcpSkipped } from '../engine/mcp-servers.js'; import { createProviderResolver, type ProviderResolver } from '../engine/providers.js'; import { openSessionStore, type OpenedSessionStore } from '../history/session-open.js'; import { CliError } from '../process/errors.js'; @@ -388,25 +387,6 @@ async function runReplLoop(wiring: ReplWiring, deps: ChatReplDeps): Promise { expect(code).toBe(EXIT_CODES.success); }); + it('a workflow agent declaring an inline stdio MCP server round-trips a tool call via run (2.R acceptance)', async () => { + const path = writeWorkflow('mcp.relavium.yaml', MCP_WF); + const { io, err } = captureIo(); + const calls: { name: string; args: unknown }[] = []; + let closed = 0; + const conn: McpConnection = { + listTools: () => + Promise.resolve([ + { name: 'read', inputSchema: { type: 'object' } }, + { name: 'danger', inputSchema: { type: 'object' } }, // dropped by the allowlist below + ]), + callTool: (name, args) => { + calls.push({ name, args }); + return Promise.resolve({ content: [{ type: 'text', text: 'fs result' }], isError: false }); + }, + close: () => { + closed += 1; + return Promise.resolve(); + }, + }; + const code = await runCommand( + { workflow: path, input: [] }, + { + io, + global: globalOptions(), + // The agent turn calls the namespaced MCP tool, then replies — driven by the scripted provider. + providers: scriptedResolver([toolUseTurn('c1', 'mcp_fs_read'), textTurn('done')]), + // Forward the engine options (incl. the composed `mcp`) but pin the deterministic in-memory host. + buildEngine: (opts) => buildEngine({ ...opts, host: createInMemoryHost() }), + // The REAL manager over a FAKE connection — no child spawns, but the real namespacing + routing run. + startMcpClient: () => + realStartMcpClient([ + { id: 'fs', toolsAllowlist: ['read'], open: () => Promise.resolve(conn) }, + ]), + }, + ); + expect(code).toBe(EXIT_CODES.success); + expect(calls).toEqual([{ name: 'read', args: {} }]); // the agent's MCP call routed to the connection + expect(err()).toContain("MCP tool 'danger'"); // the allowlist-dropped tool surfaced on stderr + expect(closed).toBe(1); // the connection was torn down at the run terminal + }); + it('renders the gate terminal as run:paused on the last NDJSON line under --json', async () => { const path = writeWorkflow('gated.relavium.yaml', GATED); const { io, out } = captureIo(); diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index 25cbe90b..df1ce778 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -6,6 +6,7 @@ import { type WorkflowDefinition, type WorkflowEngine, } from '@relavium/core'; +import type { McpClient, McpServerConfig } from '@relavium/mcp'; import { loadResolvedConfig } from '../config/load.js'; import { @@ -13,6 +14,11 @@ import { type BuildEngineOptions, } from '../engine/build-engine.js'; import { createCliHost } from '../engine/host.js'; +import { + connectWorkflowMcp, + surfaceMcpSkipped, + type WorkflowMcpRuntime, +} from '../engine/mcp-servers.js'; import { sweepHostMediaBestEffort as defaultSweepMedia, sweepMediaAtTerminal, @@ -78,6 +84,11 @@ export interface RunCommandDeps { * assert the run-end invocation without touching a real CAS, and the in-memory unit path never reaches it. */ readonly sweepMedia?: typeof defaultSweepMedia; + /** + * Injectable MCP connect-all (2.R Step 3b) — tests pass a fake that never spawns a child; production uses the + * real `@relavium/mcp` `startMcpClient`. Threads through to {@link connectWorkflowMcp}. + */ + readonly startMcpClient?: (servers: readonly McpServerConfig[]) => Promise; } /** @@ -142,13 +153,34 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr { cause: err }, ); } + let mcpRuntime: WorkflowMcpRuntime | undefined; try { + // Inbound MCP (2.R Step 3b): aggregate the `mcp_servers` declared by the workflow's INLINE agents, start + // them fail-loud (a connect/discovery failure is an exit-2 CliError, cause stripped), and rewrite the + // workflow so each inline agent's grant includes ONLY its own servers' discovered tools. `undefined` ⇒ no + // inline agent declared a server. The spawned children are torn down at the run terminal (the finally). + mcpRuntime = await connectWorkflowMcp(def, { + cwd: deps.global.cwd, + ...(deps.startMcpClient === undefined ? {} : { startMcpClient: deps.startMcpClient }), + }); + if (mcpRuntime !== undefined) surfaceMcpSkipped(deps.io, mcpRuntime.client.skipped); + const runWorkflow = mcpRuntime?.workflow ?? def; + const mcpOption = + mcpRuntime === undefined + ? {} + : { + mcp: { + toolDefs: mcpRuntime.client.toolDefs, + capability: mcpRuntime.client.capability, + }, + }; + // Media host-wiring (2.S): when durable history is open, the SAME `~/.relavium/history.db` connection // backs the `model_catalog` reader (→ `resolveMediaSurface` routing) + the `media_references` retention // junction, and the host gets the global CAS root (`~/.relavium/media/`) + the project-relative `save_to` // root (`.relavium/runs/`). Absent (the in-memory unit/harness path) ⇒ no media ports, so a media-producing // run fails loud — never a silent leak. The per-modality `media_cost_estimate` default folds in from config. - let engineOptions: BuildEngineOptions = { providers }; + let engineOptions: BuildEngineOptions = { providers, ...mcpOption }; let mediaCasRoot: string | undefined; if (opened !== undefined) { const wiring = buildMediaEngineWiring(opened.db, homeDir, deps.global.cwd, config, (m) => @@ -166,10 +198,13 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr ...(wiring.mediaCostEstimate === undefined ? {} : { mediaCostEstimate: wiring.mediaCostEstimate }), + ...mcpOption, }; } const engine = await build(engineOptions); - const handle = engine.start({ workflow: def, inputs }); + // Run the AUGMENTED workflow (each inline agent's grant unioned with its MCP tool ids); the catalog/store + // were validated against the original, which is identical except for those `tools` grants. + const handle = engine.start({ workflow: runWorkflow, inputs }); // Hand the live run to the shared driver (2.G): it owns the event loop, the SIGINT cooperative-cancel // contract, the renderer lifecycle (constructed inside, after SIGINT registration — output mode per @@ -200,5 +235,8 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr return outcomeToExitCode(outcome); } finally { opened?.close(); + // Tear down the inbound MCP connections (2.R) at the run terminal — present only when an inline agent + // declared a server; idempotent. A teardown error must never mask the run outcome (closeAll swallows). + await mcpRuntime?.client.close(); } } diff --git a/apps/cli/src/engine/build-engine.ts b/apps/cli/src/engine/build-engine.ts index bdc04c8d..b55753d6 100644 --- a/apps/cli/src/engine/build-engine.ts +++ b/apps/cli/src/engine/build-engine.ts @@ -6,6 +6,9 @@ import { createToolRegistry, type AgentRunnerDeps, type ExecutionHost, + type McpCapability, + type ToolDef, + type ToolHost, } from '@relavium/core'; import type { MediaCostEstimate, MediaSurface } from '@relavium/shared'; @@ -29,6 +32,13 @@ export interface BuildEngineOptions { * unit estimate is used. Media still folds at 0 until a verified catalog rate lands (never fabricated). */ readonly mediaCostEstimate?: MediaCostEstimate; + /** + * The inbound MCP wiring (2.R Step 3b) — the discovered namespaced `ToolDef`s + the `McpCapability` to route + * `tools/call`. Absent ⇒ the registry/host carry built-ins only. The defs are composed into BOTH the registry + * and `AgentRunnerDeps.tools` (so the granted set is surfaced to the LLM); the capability is wired onto + * `ToolHost.mcp`. The host owns the connections' lifecycle (teardown at the run terminal) — see `run.ts`. + */ + readonly mcp?: { readonly toolDefs: readonly ToolDef[]; readonly capability: McpCapability }; } /** @@ -36,17 +46,25 @@ export interface BuildEngineOptions { * (the six non-agent handlers + the agent arm), the expression sandbox, and a **fail-closed** * `ToolHost`. `host`/`providers` are injectable so tests drive a stub provider + the in-memory host. * - * The `ToolHost` is `{}` — every capability (fs / process / egress / …) is absent, so a built-in - * tool that needs one is cleanly "unavailable" rather than an insecure stub. Wiring those - * capabilities (with a dedicated security review; egress SSRF is already deferred per - * deferred-tasks/§2.S) is a follow-up workstream, not 2.D. + * The `ToolHost` carries only what is explicitly wired: when `options.mcp` is present (2.R Step 3b) it + * gets the `McpCapability` (so a granted MCP tool can `tools/call`); otherwise it is `{}` — every + * capability (fs / process / egress / …) absent, so a built-in tool that needs one is cleanly + * "unavailable" rather than an insecure stub. Wiring the remaining capabilities (with a dedicated + * security review; egress SSRF is already deferred per deferred-tasks/§2.S) is a follow-up workstream. */ export async function buildEngine(options: BuildEngineOptions = {}): Promise { const host = options.host ?? createCliHost(); const providers = options.providers ?? createProviderResolver(); const sandbox = await createExpressionSandbox(); - const registry = createToolRegistry({ tools: BUILTIN_TOOLS, host: {} }); + // Compose the inbound MCP tools (2.R Step 3b): the discovered namespaced ToolDefs join the built-ins in the + // registry AND `AgentRunnerDeps.tools` below (so a granted MCP tool is surfaced to the LLM), and the + // McpCapability is wired onto the registry's `ToolHost.mcp` so a `tools/call` routes to the owning connection. + // Absent ⇒ built-ins only + a fail-closed `{}` host (the engine's pre-2.R posture, unchanged). + const tools = + options.mcp === undefined ? BUILTIN_TOOLS : [...BUILTIN_TOOLS, ...options.mcp.toolDefs]; + const toolHost: ToolHost = options.mcp === undefined ? {} : { mcp: options.mcp.capability }; + const registry = createToolRegistry({ tools, host: toolHost }); // The single host CAS (`host.mediaStore`) also backs the D8 failover re-materialization: resolve a durable // handle in a transcript message to the in-flight source a provider needs, before egress. Bound here (when a @@ -57,7 +75,7 @@ export async function buildEngine(options: BuildEngineOptions = {}): Promise new Promise((resolveSleep) => setTimeout(resolveSleep, ms)), now: () => Date.now(), // The media routing/cost/egress deps (2.S) — each present only when its source is wired (`undefined` is diff --git a/apps/cli/src/engine/mcp-servers.test.ts b/apps/cli/src/engine/mcp-servers.test.ts index 84bc99f4..97885298 100644 --- a/apps/cli/src/engine/mcp-servers.test.ts +++ b/apps/cli/src/engine/mcp-servers.test.ts @@ -1,15 +1,23 @@ +import { parseWorkflow, type WorkflowDefinition } from '@relavium/core'; import { McpError, type McpClient, type McpServerConfig } from '@relavium/mcp'; -import type { McpServerRef } from '@relavium/shared'; +import type { Agent, McpServerRef } from '@relavium/shared'; import { describe, expect, it } from 'vitest'; import { isCliError } from '../process/errors.js'; -import { connectAgentMcp, resolveStdioServerConfigs } from './mcp-servers.js'; +import { captureIo } from '../test-support.js'; +import { + connectAgentMcp, + connectWorkflowMcp, + resolveStdioServerConfigs, + surfaceMcpSkipped, +} from './mcp-servers.js'; /** A fake live client — the injected `startMcpClient` returns it, so no child is ever spawned. */ function fakeClient(overrides: Partial = {}): McpClient { return { capability: { call: () => Promise.resolve({ content: [], isError: false }) }, toolDefs: [], + toolIdsByServer: new Map(), skipped: [], close: () => Promise.resolve(), ...overrides, @@ -124,3 +132,124 @@ describe('connectAgentMcp', () => { ).rejects.toBe(boom); }); }); + +describe('connectWorkflowMcp (run path)', () => { + // A minimal valid workflow whose inline `agents:` block is the parameter under test. + const wf = (agentsYaml: string): WorkflowDefinition => + parseWorkflow( + `schema_version: '1.0'\nworkflow:\n id: wf\n agents:\n${agentsYaml} nodes:\n - { id: s, type: input }\n - { id: a, type: agent, agent_ref: scanner, prompt_template: go }\n - { id: o, type: output }\n edges:\n - { from: s, to: a }\n - { from: a, to: o }\n`, + ); + const agentOf = (def: WorkflowDefinition, id: string): Agent => + (def.workflow.agents ?? []).find((e): e is Agent => 'id' in e && e.id === id)!; + // A fake client whose per-server grouping the augmentation reads (the configs reaching it are ignored). + const fakeStart = + (toolIdsByServer: ReadonlyMap) => (): Promise => + Promise.resolve(fakeClient({ toolIdsByServer })); + + it('returns undefined when no inline agent declares a server', async () => { + const def = wf( + ` - { id: scanner, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go }\n`, + ); + expect( + await connectWorkflowMcp(def, { cwd: '/w', startMcpClient: fakeStart(new Map()) }), + ).toBeUndefined(); + }); + + it('augments ONLY the declaring agent grant with ITS server tool ids (per-agent isolation)', async () => { + const def = wf( + [ + ' - id: scanner', + ' model: claude-sonnet-4-6', + ' provider: anthropic', + ' system_prompt: go', + ' tools: [read_file]', + ' mcp_servers: [{ id: fs, transport: stdio, command: x }]', + ' - id: other', + ' model: claude-sonnet-4-6', + ' provider: anthropic', + ' system_prompt: go', + ' tools: [git_status]', + '', + ].join('\n'), + ); + const runtime = await connectWorkflowMcp(def, { + cwd: '/w', + startMcpClient: fakeStart(new Map([['fs', ['mcp_fs_read', 'mcp_fs_write']]])), + }); + expect(runtime).toBeDefined(); + // The declaring agent's grant gains its server's ids (union with the original); the other is untouched. + expect(agentOf(runtime!.workflow, 'scanner').tools).toEqual([ + 'read_file', + 'mcp_fs_read', + 'mcp_fs_write', + ]); + expect(agentOf(runtime!.workflow, 'other').tools).toEqual(['git_status']); + }); + + it('shares ONE connection when two agents declare an identical server (dedup by id)', async () => { + let startedWith: readonly McpServerConfig[] | undefined; + const def = wf( + [ + ' - { id: scanner, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ id: fs, transport: stdio, command: x }] }', + ' - { id: writer, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ id: fs, transport: stdio, command: x }] }', + '', + ].join('\n'), + ); + const runtime = await connectWorkflowMcp(def, { + cwd: '/w', + startMcpClient: (servers) => { + startedWith = servers; + return Promise.resolve(fakeClient({ toolIdsByServer: new Map([['fs', ['mcp_fs_read']]]) })); + }, + }); + expect(startedWith).toHaveLength(1); // the duplicate `fs` declaration collapsed to one connection + // BOTH agents are granted the shared server's tools. + expect(agentOf(runtime!.workflow, 'scanner').tools).toEqual(['mcp_fs_read']); + expect(agentOf(runtime!.workflow, 'writer').tools).toEqual(['mcp_fs_read']); + }); + + it('fails loud when two agents declare the same server id with conflicting settings', async () => { + const def = wf( + [ + ' - { id: scanner, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ id: fs, transport: stdio, command: x }] }', + ' - { id: writer, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ id: fs, transport: stdio, command: DIFFERENT }] }', + '', + ].join('\n'), + ); + await expect( + connectWorkflowMcp(def, { cwd: '/w', startMcpClient: fakeStart(new Map()) }), + ).rejects.toThrow(/conflicting settings/); + }); +}); + +describe('surfaceMcpSkipped', () => { + it('writes one stderr note per dropped tool (name + server + reason), nothing on an empty list', () => { + const { io, err, out } = captureIo(); + surfaceMcpSkipped(io, []); + expect(err()).toBe(''); // nothing dropped ⇒ silent (the common case) + + surfaceMcpSkipped(io, [ + { server: 'fs', name: 'danger', reason: 'not in tools_allowlist' }, + { server: 'gh', name: 'bad-id!', reason: 'unsafe LLM tool name' }, + ]); + const lines = err().trimEnd().split('\n'); + expect(lines).toHaveLength(2); + expect(lines[0]).toContain("MCP tool 'danger' (server 'fs')"); + expect(lines[0]).toContain('not in tools_allowlist'); + expect(lines[1]).toContain("MCP tool 'bad-id!' (server 'gh')"); + expect(out()).toBe(''); // diagnostics stay on stderr — stdout (the --json stream) is untouched + }); + + it('sanitizes a hostile server-controlled tool name + reason (no terminal-escape injection reaches the TTY)', () => { + // `name`/`reason` are server-controlled and the MCP server is in-threat-model untrusted (ADR-0052 §4): a + // crafted tool returning ANSI/OSC control bytes must NOT write them raw to the operator's terminal. + const { io, err } = captureIo(); + surfaceMcpSkipped(io, [ + { server: 'fs', name: 'evil\x1b[2J\x1b]0;pwned\x07', reason: 'bad\x1b[31m schema\x1b[0m' }, + ]); + const written = err(); + // eslint-disable-next-line no-control-regex -- asserting the ABSENCE of control bytes is the point + expect(/[\x00-\x1f\x7f]/.test(written.replace(/\n$/, ''))).toBe(false); // none survived (besides the \n) + expect(written).not.toContain('\x1b'); // the ESC that opens every escape sequence is gone + }); +}); diff --git a/apps/cli/src/engine/mcp-servers.ts b/apps/cli/src/engine/mcp-servers.ts index 723ae4ef..41fc65c2 100644 --- a/apps/cli/src/engine/mcp-servers.ts +++ b/apps/cli/src/engine/mcp-servers.ts @@ -1,13 +1,17 @@ +import type { WorkflowDefinition } from '@relavium/core'; import { McpError, openStdioConnection, startMcpClient as defaultStartMcpClient, + type ManagerSkippedTool, type McpClient, type McpServerConfig, } from '@relavium/mcp'; -import type { McpServerRef } from '@relavium/shared'; +import type { Agent, AgentRef, McpServerRef } from '@relavium/shared'; import { CliError } from '../process/errors.js'; +import type { CliIo } from '../process/io.js'; +import { sanitizeInline } from '../render/tui/chat-projection.js'; /** * Resolve an agent's inline `mcp_servers` into a live {@link McpClient} (2.R Step 3 — CLI host wiring). This is @@ -86,12 +90,25 @@ export async function connectAgentMcp( ): Promise { const configs = resolveStdioServerConfigs(mcpServers, opts.cwd); if (configs.length === 0) return undefined; - const start = opts.startMcpClient ?? defaultStartMcpClient; + return startMcpClientFailLoud(configs, opts.startMcpClient); +} + +/** + * Connect the resolved server configs **fail-loud**: a connect/`tools/list` failure surfaces as a typed, exit-2 + * {@link CliError} whose message is the secret-free MCP summary — the opaque `cause` chain is intentionally NOT + * attached (the host-boundary cause-strip, ADR-0052 §2). A non-MCP error rethrows verbatim (an unexpected fault + * is never masked as `invalid_invocation`). Shared by the chat ({@link connectAgentMcp}) and run ({@link + * connectWorkflowMcp}) host paths so both surface the same typed, secret-free failure. + */ +async function startMcpClientFailLoud( + configs: readonly McpServerConfig[], + custom: ConnectAgentMcpOptions['startMcpClient'], +): Promise { + const start = custom ?? defaultStartMcpClient; try { return await start(configs); } catch (err) { if (err instanceof McpError) { - // The MCP error `message` is secret-free by contract; the opaque `cause` is dropped (never surfaced). throw new CliError('invalid_invocation', `MCP server connection failed: ${err.message}`); } throw err; @@ -120,3 +137,113 @@ function buildChildEnv( } return env; } + +/** A live MCP client plus the workflow rewritten so each inline agent's grant includes its servers' tool ids. */ +export interface WorkflowMcpRuntime { + readonly client: McpClient; + /** The input workflow with each MCP-declaring inline agent's `tools` unioned with its discovered tool ids. */ + readonly workflow: WorkflowDefinition; +} + +/** Options for {@link connectWorkflowMcp} — the run cwd + an injectable client starter (tests). */ +export interface ConnectWorkflowMcpOptions { + readonly cwd: string; + readonly startMcpClient?: (servers: readonly McpServerConfig[]) => Promise; +} + +/** + * Connect the inbound MCP servers declared by a workflow's **inline** agents for a `relavium run` (2.R Step 3b). + * It aggregates the `mcp_servers` across every inline agent ({@link Agent} entry, NOT a `$ref` — `$ref` external + * agents are not resolved in the CLI run path), **deduplicates by server id** (two agents sharing the same + * server share one connection; the same id with conflicting connection settings is a fail-loud {@link CliError}), + * starts them fail-loud, and returns the live {@link McpClient} plus a workflow whose inline agents each have + * their `tools` grant unioned with ONLY their own declared servers' discovered tool ids (per-agent isolation via + * the manager's `toolIdsByServer`). Returns `undefined` when no inline agent declares a server. Stdio only — + * a network transport fails loud in {@link resolveStdioServerConfigs} (the Step-4 follow-up). + */ +export async function connectWorkflowMcp( + def: WorkflowDefinition, + opts: ConnectWorkflowMcpOptions, +): Promise { + const inlineAgents = (def.workflow.agents ?? []).filter(isInlineAgent); + + // Dedup the declared servers by id across agents: identical spec ⇒ one shared connection; same id with a + // conflicting spec ⇒ fail loud (the namespaced tool ids would otherwise collide across two different servers). + const byId = new Map(); + for (const agent of inlineAgents) { + for (const ref of agent.mcp_servers ?? []) { + const existing = byId.get(ref.id); + if (existing === undefined) { + byId.set(ref.id, ref); + } else if (serverFingerprint(existing) !== serverFingerprint(ref)) { + throw new CliError( + 'invalid_invocation', + `MCP server '${ref.id}' is declared with conflicting settings by more than one agent — ` + + `give the distinct servers distinct ids.`, + ); + } + } + } + if (byId.size === 0) return undefined; + + const configs = resolveStdioServerConfigs([...byId.values()], opts.cwd); + const client = await startMcpClientFailLoud(configs, opts.startMcpClient); + + // Augment each inline agent's grant with ONLY its own servers' discovered ids (a `$ref` entry passes through). + const agents = (def.workflow.agents ?? []).map((entry) => + isInlineAgent(entry) ? withWorkflowMcpGrant(entry, client.toolIdsByServer) : entry, + ); + const workflow: WorkflowDefinition = { + ...def, + workflow: { ...def.workflow, agents }, + }; + return { client, workflow }; +} + +/** True for an inline agent definition (carries an `id`), false for a `{ $ref }` external reference. */ +function isInlineAgent(entry: Agent | AgentRef): entry is Agent { + return 'id' in entry; +} + +/** Union an inline agent's `tools` grant with its OWN declared servers' discovered tool ids (2.R, ADR-0052 §3). */ +function withWorkflowMcpGrant( + agent: Agent, + toolIdsByServer: ReadonlyMap, +): Agent { + const ids = (agent.mcp_servers ?? []).flatMap((server) => toolIdsByServer.get(server.id) ?? []); + if (ids.length === 0) return agent; + return { ...agent, tools: [...new Set([...(agent.tools ?? []), ...ids])] }; +} + +/** + * A stable fingerprint of a server's connection settings — equal iff two declarations describe the SAME server, + * so a duplicate id with identical settings dedups while a conflicting one fails loud. `env` keys are sorted + * (a map is order-insensitive); `args` order is preserved (a command line is ordered). + */ +function serverFingerprint(ref: McpServerRef): string { + const env = Object.entries(ref.env ?? {}).sort(([a], [b]) => a.localeCompare(b)); + return JSON.stringify({ + t: ref.transport, + c: ref.command ?? null, + a: ref.args ?? [], + u: ref.url ?? null, + e: env, + }); +} + +/** + * Surface MCP tools dropped at discovery (allowlist-narrowed, an unsupported schema, a cross-server id + * collision, or an unsafe name) to **stderr** — a non-fatal diagnostic that never pollutes a `--json` stdout + * stream. A no-op when nothing was dropped (the common case). Shared by the chat and run host surfaces. + * + * The tool `name` and `reason` are **server-controlled** and the MCP server is in-threat-model untrusted + * (ADR-0052 §4), so both — and the `server` segment, future-proofing the by-name `ref` form — are run through + * {@link sanitizeInline} (the terminal-escape strip the resume banner / slash echo / streamed tokens use). + */ +export function surfaceMcpSkipped(io: CliIo, skipped: readonly ManagerSkippedTool[]): void { + for (const tool of skipped) { + io.writeErr( + `note: MCP tool '${sanitizeInline(tool.name)}' (server '${sanitizeInline(tool.server)}') skipped — ${sanitizeInline(tool.reason)}\n`, + ); + } +} diff --git a/packages/mcp/src/manager.test.ts b/packages/mcp/src/manager.test.ts index 6af45b2f..5c3c1af3 100644 --- a/packages/mcp/src/manager.test.ts +++ b/packages/mcp/src/manager.test.ts @@ -43,6 +43,32 @@ describe('startMcpClient', () => { await client.close(); }); + it('groups the granted tool ids by server (toolIdsByServer) — the host augments the right agent grant', async () => { + const client = await startMcpClient([ + { id: 'github', open: () => Promise.resolve(fakeConnection([t('create_issue')])) }, + { id: 'fs', open: () => Promise.resolve(fakeConnection([t('read_file'), t('write')])) }, + ]); + expect(client.toolIdsByServer.get('github')).toEqual(['mcp_github_create_issue']); + expect(client.toolIdsByServer.get('fs')).toEqual(['mcp_fs_read_file', 'mcp_fs_write']); + // The grouping covers exactly the connected servers (a declared id always has an entry). + expect([...client.toolIdsByServer.keys()].sort()).toEqual(['fs', 'github']); + await client.close(); + }); + + it('keeps an empty grouping entry for a server whose tools were all dropped (a declared id is always present)', async () => { + // `fs` exposes only a tool excluded by its allowlist ⇒ zero granted defs, but the id must still map (to []). + const client = await startMcpClient([ + { + id: 'fs', + toolsAllowlist: ['keep'], + open: () => Promise.resolve(fakeConnection([t('drop')])), + }, + ]); + expect(client.toolIdsByServer.get('fs')).toEqual([]); + expect(client.toolDefs).toEqual([]); + await client.close(); + }); + it('routes a tool call to the owning connection (by server id)', async () => { const calls: string[] = []; const conn = (id: string): McpConnection => diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts index 964e4bc3..eb8ad937 100644 --- a/packages/mcp/src/manager.ts +++ b/packages/mcp/src/manager.ts @@ -37,6 +37,13 @@ export interface McpClient { readonly capability: McpCapability; /** The aggregate namespaced `ToolDef`s across all servers — compose into `createToolRegistry({ tools })`. */ readonly toolDefs: readonly ToolDef[]; + /** + * The granted (post-allowlist, post-collision) namespaced tool ids **grouped by server id** — the host uses + * this to augment the RIGHT agent's tool grant when several agents in one workflow declare different servers + * ([ADR-0052](../../../docs/decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §3). A server + * that contributed no usable tool still has an entry (an empty array), so a declared id is always present. + */ + readonly toolIdsByServer: ReadonlyMap; /** Tools dropped at discovery, per server. */ readonly skipped: readonly ManagerSkippedTool[]; /** Tear down every connection (idempotent). */ @@ -46,6 +53,7 @@ export interface McpClient { export async function startMcpClient(servers: readonly McpServerConfig[]): Promise { const connections = new Map(); const toolDefs: ToolDef[] = []; + const toolIdsByServer = new Map(); const skipped: ManagerSkippedTool[] = []; // Shared ACROSS servers so a namespaced id colliding across two servers (e.g. server `a`+tool `b_x` and // server `a_b`+tool `x` both → `mcp_a_b_x`) fails closed — never a duplicate id reaching `createToolRegistry`. @@ -67,6 +75,10 @@ export async function startMcpClient(servers: readonly McpServerConfig[]): Promi const tools = await connection.listTools(); const shaped = buildServerToolDefs(server.id, tools, server.toolsAllowlist, seenToolIds); toolDefs.push(...shaped.defs); + toolIdsByServer.set( + server.id, + shaped.defs.map((def) => def.id), + ); for (const s of shaped.skipped) { skipped.push({ server: server.id, name: s.name, reason: s.reason }); } @@ -90,7 +102,7 @@ export async function startMcpClient(servers: readonly McpServerConfig[]): Promi }, }; - return { capability, toolDefs, skipped, close: () => closeAll(connections) }; + return { capability, toolDefs, toolIdsByServer, skipped, close: () => closeAll(connections) }; } /** Close every connection, swallowing teardown errors (the children are exiting); clears the map (idempotent). */ From 3ac6d0b4098459a12365f014a8097ccfa4991915 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 27 Jun 2026 10:32:57 +0300 Subject: [PATCH 05/24] =?UTF-8?q?fix(cli):=202.R=20Step=203b=20Opus-review?= =?UTF-8?q?=20=E2=80=94=20close=20cross-agent=20allowlist=20escalation=20i?= =?UTF-8?q?n=20MCP=20dedup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed HIGH from the adversarial review of cfad5cd: - [HIGH] `serverFingerprint` omitted `tools_allowlist`, so two inline agents sharing a server id with DIFFERENT allowlists silently collapsed to ONE connection under the first-declared allowlist — and BOTH agents were granted that union. An agent restricting itself to `[read]` was thereby handed `write`/`delete` whenever another agent declared the same server id with a broader/absent allowlist: a privilege escalation past the narrower agent's own declared grant (ADR-0029 narrow-only). Fix: `tools_allowlist` is now part of the dedup identity (normalized — `undefined`=all-tools is a distinct sentinel from `[]`, sorted as a set), so a same-id pair with differing allowlists hits the existing fail-loud "conflicting settings" CliError. One physical connection cannot honor two allowlists, so failing closed is correct. Plus the review's two coverage gaps: - [MEDIUM] add a two-different-servers isolation test (agent A gets fs only, B gets gh only). - [LOW] add a `$ref`-alongside-inline test (the `$ref` passes through byte-identical) and a run-terminal teardown test (the MCP child closes even when the engine build fails after a successful connect). Plus a same-id-same-allowlist (order-insensitive) dedup test. The 3 refuted findings were correctly non-issues (a weaker variant of the allowlist note, two "add a defensive test" hardening requests). lint/typecheck/test (639) + format green. Refs: ADR-0029, ADR-0052 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/commands/run.test.ts | 29 +++++++++ apps/cli/src/engine/mcp-servers.test.ts | 87 +++++++++++++++++++++++++ apps/cli/src/engine/mcp-servers.ts | 20 +++++- 3 files changed, 133 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/commands/run.test.ts b/apps/cli/src/commands/run.test.ts index 1c0a4240..cccb27cc 100644 --- a/apps/cli/src/commands/run.test.ts +++ b/apps/cli/src/commands/run.test.ts @@ -1096,6 +1096,35 @@ describe('runCommand', () => { expect(closed).toBe(1); // the connection was torn down at the run terminal }); + it('tears the MCP connection down even when the engine fails AFTER a successful connect (run-terminal teardown)', async () => { + const path = writeWorkflow('mcp-fail.relavium.yaml', MCP_WF); + const { io } = captureIo(); + let closed = 0; + const conn: McpConnection = { + listTools: () => Promise.resolve([{ name: 'read', inputSchema: { type: 'object' } }]), + callTool: () => Promise.resolve({ content: [], isError: false }), + close: () => { + closed += 1; + return Promise.resolve(); + }, + }; + await expect( + runCommand( + { workflow: path, input: [] }, + { + io, + global: globalOptions(), + providers: scriptedResolver([textTurn('unused')]), + // The connect succeeds, then the engine build fails — the run-terminal finally must still close the MCP child. + buildEngine: () => Promise.reject(new Error('engine build boom')), + startMcpClient: () => + realStartMcpClient([{ id: 'fs', open: () => Promise.resolve(conn) }]), + }, + ), + ).rejects.toThrow('engine build boom'); + expect(closed).toBe(1); // the connection connectWorkflowMcp opened was torn down despite the build failure + }); + it('renders the gate terminal as run:paused on the last NDJSON line under --json', async () => { const path = writeWorkflow('gated.relavium.yaml', GATED); const { io, out } = captureIo(); diff --git a/apps/cli/src/engine/mcp-servers.test.ts b/apps/cli/src/engine/mcp-servers.test.ts index 97885298..9bcc26fa 100644 --- a/apps/cli/src/engine/mcp-servers.test.ts +++ b/apps/cli/src/engine/mcp-servers.test.ts @@ -220,6 +220,93 @@ describe('connectWorkflowMcp (run path)', () => { connectWorkflowMcp(def, { cwd: '/w', startMcpClient: fakeStart(new Map()) }), ).rejects.toThrow(/conflicting settings/); }); + + it('fails loud when two agents share a server id but declare DIFFERENT tools_allowlist (no escalation)', async () => { + // One physical connection cannot honor two allowlists — collapsing them would grant BOTH agents the union, + // escalating the narrower agent past its declared grant. `tools_allowlist` is part of the dedup identity. + const narrowVsNarrow = wf( + [ + ' - { id: scanner, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ id: fs, transport: stdio, command: x, tools_allowlist: [read, write] }] }', + ' - { id: writer, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ id: fs, transport: stdio, command: x, tools_allowlist: [read] }] }', + '', + ].join('\n'), + ); + await expect( + connectWorkflowMcp(narrowVsNarrow, { cwd: '/w', startMcpClient: fakeStart(new Map()) }), + ).rejects.toThrow(/conflicting settings/); + + // The escalation direction: absent allowlist (all tools) vs an explicit narrow — also a conflict. + const absentVsNarrow = wf( + [ + ' - { id: scanner, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ id: fs, transport: stdio, command: x }] }', + ' - { id: writer, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ id: fs, transport: stdio, command: x, tools_allowlist: [read] }] }', + '', + ].join('\n'), + ); + await expect( + connectWorkflowMcp(absentVsNarrow, { cwd: '/w', startMcpClient: fakeStart(new Map()) }), + ).rejects.toThrow(/conflicting settings/); + }); + + it('shares one connection when two agents share a server id with the SAME allowlist (order-insensitive)', async () => { + // The allowlist is a set — declaration order must NOT spuriously conflict. `[read, write]` ≡ `[write, read]`. + let startedWith: readonly McpServerConfig[] | undefined; + const def = wf( + [ + ' - { id: scanner, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ id: fs, transport: stdio, command: x, tools_allowlist: [read, write] }] }', + ' - { id: writer, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ id: fs, transport: stdio, command: x, tools_allowlist: [write, read] }] }', + '', + ].join('\n'), + ); + const runtime = await connectWorkflowMcp(def, { + cwd: '/w', + startMcpClient: (servers) => { + startedWith = servers; + return Promise.resolve(fakeClient({ toolIdsByServer: new Map([['fs', ['mcp_fs_read']]]) })); + }, + }); + expect(startedWith).toHaveLength(1); // same set, different order ⇒ one shared connection (no false conflict) + expect(agentOf(runtime!.workflow, 'scanner').tools).toEqual(['mcp_fs_read']); + }); + + it('isolates grants across agents declaring DIFFERENT servers (A gets fs only, B gets gh only)', async () => { + const def = wf( + [ + ' - { id: scanner, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ id: fs, transport: stdio, command: x }] }', + ' - { id: writer, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ id: gh, transport: stdio, command: y }] }', + '', + ].join('\n'), + ); + const runtime = await connectWorkflowMcp(def, { + cwd: '/w', + startMcpClient: fakeStart( + new Map([ + ['fs', ['mcp_fs_read']], + ['gh', ['mcp_gh_issue']], + ]), + ), + }); + // Each agent is granted ONLY its own server's tools — never the other's. + expect(agentOf(runtime!.workflow, 'scanner').tools).toEqual(['mcp_fs_read']); + expect(agentOf(runtime!.workflow, 'writer').tools).toEqual(['mcp_gh_issue']); + }); + + it('leaves a $ref agent entry byte-identical, augmenting only the inline agent', async () => { + const def = wf( + [ + ' - { $ref: ./reviewer.agent.yaml }', + ' - { id: scanner, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ id: fs, transport: stdio, command: x }] }', + '', + ].join('\n'), + ); + const runtime = await connectWorkflowMcp(def, { + cwd: '/w', + startMcpClient: fakeStart(new Map([['fs', ['mcp_fs_read']]])), + }); + const entries = runtime!.workflow.workflow.agents ?? []; + expect(entries[0]).toEqual({ $ref: './reviewer.agent.yaml' }); // the $ref passes through untouched + expect(agentOf(runtime!.workflow, 'scanner').tools).toEqual(['mcp_fs_read']); + }); }); describe('surfaceMcpSkipped', () => { diff --git a/apps/cli/src/engine/mcp-servers.ts b/apps/cli/src/engine/mcp-servers.ts index 41fc65c2..9cc02873 100644 --- a/apps/cli/src/engine/mcp-servers.ts +++ b/apps/cli/src/engine/mcp-servers.ts @@ -216,18 +216,32 @@ function withWorkflowMcpGrant( } /** - * A stable fingerprint of a server's connection settings — equal iff two declarations describe the SAME server, - * so a duplicate id with identical settings dedups while a conflicting one fails loud. `env` keys are sorted - * (a map is order-insensitive); `args` order is preserved (a command line is ordered). + * A stable fingerprint of a server's IDENTITY for cross-agent dedup — equal iff two declarations describe the + * SAME server with the SAME effective grant, so a duplicate id with identical settings shares one connection + * while a conflicting one fails loud. `env` keys + `tools_allowlist` are sorted (both order-insensitive sets); + * `args` order is preserved (a command line is ordered). + * + * **`tools_allowlist` is part of the identity** (not just the connection): two agents sharing a server id resolve + * to ONE physical connection whose tools are discovered ONCE under ONE allowlist — it cannot honor two different + * allowlists. Were the allowlist excluded, a same-id pair with `[read]` vs `[read,write]` would silently collapse + * to whichever was declared first, granting BOTH agents the union (a privilege escalation past the narrower + * agent's own declared `tools_allowlist`, violating ADR-0029 narrow-only). Including it makes that pair fail + * loud, forcing the author to align the allowlists or give the distinct servers distinct ids. `undefined` + * (all-tools) is a distinct sentinel from `[]` (none). */ function serverFingerprint(ref: McpServerRef): string { const env = Object.entries(ref.env ?? {}).sort(([a], [b]) => a.localeCompare(b)); + const allowlist = + ref.tools_allowlist === undefined + ? null + : [...ref.tools_allowlist].sort((a, b) => a.localeCompare(b)); return JSON.stringify({ t: ref.transport, c: ref.command ?? null, a: ref.args ?? [], u: ref.url ?? null, e: env, + w: allowlist, }); } From 53c9c3483ef50b0f1712b93fb635e9fe76511a43 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 27 Jun 2026 10:44:19 +0300 Subject: [PATCH 06/24] =?UTF-8?q?test(cli):=202.R=20Step=203b=20Sonnet-rev?= =?UTF-8?q?iew=20=E2=80=94=20run-path=20fail-loud=20+=20buildEngine=20mcp-?= =?UTF-8?q?arm=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two confirmed LOW test-coverage gaps from the second-model review (no production defect — the code is correct): - The run-path fail-loud connect arm (connectWorkflowMcp rejects → exit-2 CliError through runCommand, cause stripped, before the engine builds) was covered only transitively via connectAgentMcp. Add a run.test.ts case: startMcpClient rejects with McpError → runCommand rejects invalid_invocation, message secret-free, cause undefined, engine never built. - buildEngine's `options.mcp` composition arm had no focused unit test (only the run e2e). Add a wiring-level case mirroring the file's 2.S pattern. (The registry `tools` and `AgentRunnerDeps.tools` are built from one shared const, so they cannot structurally diverge; deep routing stays the run e2e's job, per the file's stated scope.) The other 6 raw findings were correctly refuted. lint/typecheck/test (641) + format green. Step 3b review loop complete. Refs: ADR-0052 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/commands/run.test.ts | 34 +++++++++++++++++++++++- apps/cli/src/engine/build-engine.test.ts | 20 ++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/commands/run.test.ts b/apps/cli/src/commands/run.test.ts index cccb27cc..4854a1df 100644 --- a/apps/cli/src/commands/run.test.ts +++ b/apps/cli/src/commands/run.test.ts @@ -17,7 +17,7 @@ import { runMigrations, type Db, } from '@relavium/db'; -import { startMcpClient as realStartMcpClient, type McpConnection } from '@relavium/mcp'; +import { McpError, startMcpClient as realStartMcpClient, type McpConnection } from '@relavium/mcp'; import { RunEventSchema } from '@relavium/shared'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -1096,6 +1096,38 @@ describe('runCommand', () => { expect(closed).toBe(1); // the connection was torn down at the run terminal }); + it('a failed MCP connect is a clean fail-loud exit-2 CliError (cause stripped) before the engine is built', async () => { + const path = writeWorkflow('mcp-connect-fail.relavium.yaml', MCP_WF); + const { io } = captureIo(); + let engineBuilt = false; + let caught: unknown; + try { + await runCommand( + { workflow: path, input: [] }, + { + io, + global: globalOptions(), + providers: scriptedResolver([textTurn('unused')]), + buildEngine: () => { + engineBuilt = true; + return buildEngine({ host: createInMemoryHost() }); + }, + // The connect fails — `connectWorkflowMcp` runs BEFORE the engine is built, so this fails loud first. + startMcpClient: () => Promise.reject(new McpError('spawn failed for "fs"')), + }, + ); + } catch (err) { + caught = err; + } + expect(isCliError(caught)).toBe(true); + if (isCliError(caught)) { + expect(caught.code).toBe('invalid_invocation'); // a clean exit-2 invocation fault + expect(caught.message).toContain('spawn failed for "fs"'); // the secret-free MCP summary + } + expect((caught as Error).cause).toBeUndefined(); // the opaque MCP cause chain is never attached + expect(engineBuilt).toBe(false); // the connect failed before the engine was ever built (no leak) + }); + it('tears the MCP connection down even when the engine fails AFTER a successful connect (run-terminal teardown)', async () => { const path = writeWorkflow('mcp-fail.relavium.yaml', MCP_WF); const { io } = captureIo(); diff --git a/apps/cli/src/engine/build-engine.test.ts b/apps/cli/src/engine/build-engine.test.ts index fe16a503..c5736046 100644 --- a/apps/cli/src/engine/build-engine.test.ts +++ b/apps/cli/src/engine/build-engine.test.ts @@ -2,7 +2,9 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import type { McpCapability } from '@relavium/core'; import { createClient, runMigrations } from '@relavium/db'; +import { buildServerToolDefs } from '@relavium/mcp'; import { afterEach, describe, expect, it } from 'vitest'; import { buildEngine } from './build-engine.js'; @@ -46,3 +48,21 @@ describe('buildEngine media wiring (2.S)', () => { expect(engine).toBeDefined(); }); }); + +/** + * Wiring-level coverage for the 2.R `options.mcp` arm (composes the discovered ToolDefs into the registry + + * `AgentRunnerDeps.tools` and the `McpCapability` onto `ToolHost.mcp`). Note the registry `tools` and + * `AgentRunnerDeps.tools` are assembled from the SAME `tools` const, so they cannot structurally diverge. The + * deep behavior — a granted MCP tool surfacing to the LLM and routing a `tools/call` — is the `relavium run` + * acceptance e2e (`commands/run.test.ts`); here we assert the assembler accepts + binds the option without throw. + */ +describe('buildEngine MCP wiring (2.R)', () => { + it('accepts the mcp option (discovered ToolDefs + capability) and binds without throwing', async () => { + const { defs } = buildServerToolDefs('fs', [{ name: 'read', inputSchema: { type: 'object' } }]); + const capability: McpCapability = { + call: () => Promise.resolve({ content: [], isError: false }), + }; + const engine = await buildEngine({ mcp: { toolDefs: defs, capability } }); + expect(engine).toBeDefined(); + }); +}); From 758aeb0ba84393b402c6455632d4196ff97817cd Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 27 Jun 2026 11:54:06 +0300 Subject: [PATCH 07/24] =?UTF-8?q?feat(cli):=202.R=20Step=204a=20=E2=80=94?= =?UTF-8?q?=20named=20MCP=20secrets=20via=20the=20isolated=20mcp-secret:*?= =?UTF-8?q?=20keychain=20namespace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve `{{secrets.}}` in an MCP server's `env` through an isolated chain — keychain `mcp-secret:` → `RELAVIUM_MCP_` env var → fail-closed — injecting the value ONLY into the spawned child's environment (ADR-0052 §6). Never a committed file, a log, an event, or a `--json` line. - `secrets/mcp-secret.ts`: `createMcpSecretResolver(env, keychain?)` + `mcpSecretAccount`/`mcpSecretEnvVar`. The namespace is isolated by construction: it reads ONLY `mcp-secret:*` accounts + `RELAVIUM_MCP_*` env, so `{{secrets.anthropic}}` resolves an unrelated `mcp-secret:anthropic` account — NEVER the provider key (`anthropic:default` / `RELAVIUM_ANTHROPIC_API_KEY`), closing the imported-workflow exfil path. A missing secret / invalid name is a typed, secret-free exit-2 CliError; an unavailable keychain falls through to the env var. - `engine/mcp-servers.ts`: `buildChildEnv` interpolates `{{secrets.}}` via the injected resolver; any other `{{…}}` (or a `{{secrets}}` with no resolver) is rejected loud (never a literal placeholder to the server). Threaded through `connectAgentMcp` / `connectWorkflowMcp`. - `chat` / `chat-resume` / `agent run` / `run` accept an injected resolver (default env-only); `specs.ts` wires the keychain-backed one, sharing one native keychain accessor with the provider-key resolver. - keychain-and-secrets.md §Entry naming documents the `mcp-secret:` scheme. Tests: the resolver chain + the namespace-isolation exfil scenario + name charset + unavailable-backend fallthrough; `buildChildEnv` interpolation (whole + embedded), fail-closed propagation, unsupported-interpolation reject; and the build-level threading (a missing secret fails the chat build closed before any spawn). lint/typecheck/test (656)/build + format green. Refs: ADR-0006, ADR-0019, ADR-0052 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/chat/session-host.test.ts | 40 +++++++- apps/cli/src/chat/session-host.ts | 11 +++ apps/cli/src/commands/agent-run.ts | 4 + apps/cli/src/commands/chat.ts | 7 ++ apps/cli/src/commands/run.ts | 4 + apps/cli/src/commands/specs.ts | 18 +++- apps/cli/src/engine/mcp-servers.test.ts | 45 ++++++++- apps/cli/src/engine/mcp-servers.ts | 46 ++++++--- apps/cli/src/secrets/mcp-secret.test.ts | 93 +++++++++++++++++++ apps/cli/src/secrets/mcp-secret.ts | 78 ++++++++++++++++ .../reference/desktop/keychain-and-secrets.md | 7 ++ 11 files changed, 334 insertions(+), 19 deletions(-) create mode 100644 apps/cli/src/secrets/mcp-secret.test.ts create mode 100644 apps/cli/src/secrets/mcp-secret.ts diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index 21ef0f2a..7240d3fa 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -14,6 +14,7 @@ import type { AgentSessionRecord, SessionMessage } from '@relavium/shared'; import { describe, expect, it } from 'vitest'; import type { ResolvedChatConfig } from '../config/resolve.js'; +import { createMcpSecretResolver } from '../secrets/mcp-secret.js'; import { buildDefaultChatAgent } from './default-agent.js'; import { buildChatSession, @@ -150,8 +151,8 @@ describe('buildChatSession', () => { }); describe('buildChatSession + MCP host wiring (2.R)', () => { - /** Write a throwaway `.agent.yaml` declaring one inline stdio MCP server, returning its path. */ - function writeMcpAgent(): string { + /** Write a throwaway `.agent.yaml` declaring one inline stdio MCP server (optional extra server lines). */ + function writeMcpAgent(extraServerLines: readonly string[] = []): string { const dir = mkdtempSync(join(tmpdir(), 'relavium-mcp-')); const path = join(dir, 'a.agent.yaml'); writeFileSync( @@ -167,6 +168,7 @@ describe('buildChatSession + MCP host wiring (2.R)', () => { ' - id: fs', ' transport: stdio', ' command: my-server', + ...extraServerLines, '', ].join('\n'), ); @@ -264,6 +266,40 @@ describe('buildChatSession + MCP host wiring (2.R)', () => { await expect(building).rejects.toThrow(/duplicate tool id/); expect(closed).toBe(1); // the build closed the client it had just opened }); + + it('threads the secret resolver to the child env: a MISSING {{secrets}} fails the build closed, never spawns', async () => { + // The resolver runs in resolveStdioServerConfigs (inside connectAgentMcp) BEFORE startMcpClient — so a + // missing secret fails the build loud, never reaching the (fake) client. Proves the full + // command→build→connect→env-resolution threading + the fail-closed posture. + let started = false; + const building = build({ + agentRef: writeMcpAgent([' env:', ' TOKEN: "{{secrets.missing}}"']), + mcpSecretResolver: createMcpSecretResolver({}), // empty env, no keychain ⇒ 'missing' is unresolvable + startMcpClient: () => { + started = true; + return Promise.reject( + new Error('startMcpClient must not be reached on a fail-closed secret'), + ); + }, + }); + await expect(building).rejects.toThrow(/secret 'missing' is not set/); + expect(started).toBe(false); // failed at env resolution, before any connect + }); + + it('threads the resolver: a resolvable secret lets the build proceed (no spurious fail-closed)', async () => { + const conn: McpConnection = { + listTools: () => Promise.resolve([]), + callTool: () => Promise.resolve({ content: [], isError: false }), + close: () => Promise.resolve(), + }; + const built = await build({ + agentRef: writeMcpAgent([' env:', ' TOKEN: "{{secrets.gh}}"']), + mcpSecretResolver: createMcpSecretResolver({ RELAVIUM_MCP_GH: 'ghp_resolved' }), + startMcpClient: () => realStartMcpClient([{ id: 'fs', open: () => Promise.resolve(conn) }]), + }); + expect(built.closeMcp).toBeDefined(); // the secret resolved ⇒ the build proceeded to connect + await built.closeMcp?.(); + }); }); describe('buildResumedChatSession (2.N)', () => { diff --git a/apps/cli/src/chat/session-host.ts b/apps/cli/src/chat/session-host.ts index 70fa701a..1311c843 100644 --- a/apps/cli/src/chat/session-host.ts +++ b/apps/cli/src/chat/session-host.ts @@ -21,6 +21,7 @@ import type { ResolvedChatConfig } from '../config/resolve.js'; import { connectAgentMcp } from '../engine/mcp-servers.js'; import { createProviderResolver, type ProviderResolver } from '../engine/providers.js'; import { CliError } from '../process/errors.js'; +import type { McpSecretResolver } from '../secrets/mcp-secret.js'; import { resolveChatAgent } from './agent-source.js'; /** @@ -57,6 +58,12 @@ export interface BuildChatSessionOptions { * `mcp_servers` discover their tools without a live server in the unit path. */ readonly startMcpClient?: (servers: readonly McpServerConfig[]) => Promise; + /** + * Resolve a `{{secrets.}}` placeholder in an MCP server `env` value (2.R Step 4, ADR-0052 §6). The + * command wires the isolated `mcp-secret:*` keychain → `RELAVIUM_MCP_*` env chain; absent ⇒ a `{{` env value + * is rejected loud. + */ + readonly mcpSecretResolver?: McpSecretResolver; /** * Session-scoped `{{ctx.*}}` variables (plaintext, NO secrets — agent-session-spec.md §Tools). `relavium * agent run --input k=v` (2.Q) populates these; a bare `chat` leaves them unset. @@ -186,6 +193,7 @@ export async function buildChatSession(opts: BuildChatSessionOptions): Promise Promise; + /** Resolve `{{secrets.}}` in an MCP server `env` (2.R Step 4; see {@link BuildChatSessionOptions.mcpSecretResolver}). */ + readonly mcpSecretResolver?: McpSecretResolver; /** Sink for an `on_exceed: 'warn'` pre-egress budget warning (see {@link BuildChatSessionOptions}). */ readonly onBudgetWarning?: (warning: ChatBudgetWarning) => void; } @@ -296,6 +306,7 @@ export async function buildResumedChatSession( const mcp = await connectAgentMcp(agent.mcp_servers, { cwd: context.workingDir, ...(opts.startMcpClient === undefined ? {} : { startMcpClient: opts.startMcpClient }), + ...(opts.mcpSecretResolver === undefined ? {} : { resolveSecret: opts.mcpSecretResolver }), }); try { diff --git a/apps/cli/src/commands/agent-run.ts b/apps/cli/src/commands/agent-run.ts index 0f05f77c..58b2221d 100644 --- a/apps/cli/src/commands/agent-run.ts +++ b/apps/cli/src/commands/agent-run.ts @@ -12,6 +12,7 @@ import { CliError } from '../process/errors.js'; import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; +import { createMcpSecretResolver, type McpSecretResolver } from '../secrets/mcp-secret.js'; import { makePlainPrinter } from './chat.js'; /** @@ -40,6 +41,8 @@ export interface AgentRunCommandDeps { readonly providers?: ProviderResolver; /** Injectable session builder (tests). Default {@link buildChatSession}. */ readonly buildSession?: typeof buildChatSession; + /** The MCP named-secret resolver (2.R Step 4) — production injects the keychain-backed one; default env-only. */ + readonly mcpSecretResolver?: McpSecretResolver; readonly now?: () => number; readonly uuid?: () => string; } @@ -93,6 +96,7 @@ export async function agentRunCommand( now, uuid, providers, + mcpSecretResolver: deps.mcpSecretResolver ?? createMcpSecretResolver(deps.io.env), }); // Render the live stream (NDJSON under --json, else the plain token/tool printer) and capture the turn // outcome — a classified turn failure completes with `session:turn_completed.error`, mapping to exit 1. diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index 7ceb83cb..436e04ca 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -27,6 +27,7 @@ import { stripTerminalControls, } from '../render/tui/chat-projection.js'; import { createChatStore, type ChatStoreController } from '../render/tui/chat-store.js'; +import { createMcpSecretResolver, type McpSecretResolver } from '../secrets/mcp-secret.js'; /** * `relavium chat` (2.M) — the agent-first interactive REPL over `@relavium/core`'s `AgentSession`. It binds @@ -83,6 +84,8 @@ export interface ChatCommandDeps { readonly buildSession?: typeof buildChatSession; /** Injectable session-store opener (tests pass an in-memory store). Default {@link openSessionStore}. */ readonly openSessionStore?: (homeDir: string) => OpenedSessionStore; + /** The MCP named-secret resolver (2.R Step 4) — production injects the keychain-backed one (specs.ts); default env-only. */ + readonly mcpSecretResolver?: McpSecretResolver; /** The interactive driver — defaults to the plain non-TTY line loop; the TTY ink driver + tests override it. */ readonly drive?: ChatDriver; /** Wall-clock (ms) + id sources (injectable for tests). */ @@ -103,6 +106,8 @@ export interface ChatResumeCommandDeps { readonly buildResumedSession?: typeof buildResumedChatSession; /** Injectable session-store opener (tests pass an in-memory store). Default {@link openSessionStore}. */ readonly openSessionStore?: (homeDir: string) => OpenedSessionStore; + /** The MCP named-secret resolver (2.R Step 4) — production injects the keychain-backed one; default env-only. */ + readonly mcpSecretResolver?: McpSecretResolver; /** The interactive driver — defaults to the plain non-TTY line loop; the TTY ink driver + tests override it. */ readonly drive?: ChatDriver; /** Wall-clock (ms) + id sources (injectable for tests). */ @@ -141,6 +146,7 @@ export async function chatCommand(args: ChatCommandArgs, deps: ChatCommandDeps): now, uuid, providers, + mcpSecretResolver: deps.mcpSecretResolver ?? createMcpSecretResolver(deps.io.env), onBudgetWarning: (warning) => deps.io.writeErr( `budget warning: ~${warning.thresholdPct}% of the ${warning.limitMicrocents}µ¢ cap reached\n`, @@ -217,6 +223,7 @@ export async function chatResumeCommand( messages: loaded.messages, now, providers, + mcpSecretResolver: deps.mcpSecretResolver ?? createMcpSecretResolver(deps.io.env), onBudgetWarning: (warning) => deps.io.writeErr( `budget warning: ~${warning.thresholdPct}% of the ${warning.limitMicrocents}µ¢ cap reached\n`, diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index df1ce778..ce687e5f 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -38,6 +38,7 @@ import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; import type { RunRenderer } from '../render/renderer.js'; import { selectRenderer } from '../render/select.js'; +import { createMcpSecretResolver, type McpSecretResolver } from '../secrets/mcp-secret.js'; import { resolveWorkflowSource } from '../workflows/resolve.js'; import { assertWorkflowCatalogValid, @@ -89,6 +90,8 @@ export interface RunCommandDeps { * real `@relavium/mcp` `startMcpClient`. Threads through to {@link connectWorkflowMcp}. */ readonly startMcpClient?: (servers: readonly McpServerConfig[]) => Promise; + /** The MCP named-secret resolver (2.R Step 4) — production injects the keychain-backed one; default env-only. */ + readonly mcpSecretResolver?: McpSecretResolver; } /** @@ -161,6 +164,7 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr // inline agent declared a server. The spawned children are torn down at the run terminal (the finally). mcpRuntime = await connectWorkflowMcp(def, { cwd: deps.global.cwd, + resolveSecret: deps.mcpSecretResolver ?? createMcpSecretResolver(deps.io.env), ...(deps.startMcpClient === undefined ? {} : { startMcpClient: deps.startMcpClient }), }); if (mcpRuntime !== undefined) surfaceMcpSkipped(deps.io, mcpRuntime.client.skipped); diff --git a/apps/cli/src/commands/specs.ts b/apps/cli/src/commands/specs.ts index 1d56a346..a5528e3b 100644 --- a/apps/cli/src/commands/specs.ts +++ b/apps/cli/src/commands/specs.ts @@ -13,6 +13,7 @@ import { type ExitCode } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; import { selectChatDriver } from '../render/tui/chat-ink.js'; +import { createMcpSecretResolver } from '../secrets/mcp-secret.js'; import { createOsKeychainStore } from '../secrets/os-keychain.js'; import { readSecretFromStdin } from '../secrets/read-secret.js'; import { agentRunCommand } from './agent-run.js'; @@ -124,6 +125,8 @@ function registerRun(program: Command, ctx?: CommandContext): void { } run.action(async (workflow: string, opts: { input?: readonly string[] }) => { + // One native keychain accessor, shared by the key resolver (2.C) + the MCP named-secret resolver (2.R §6). + const keychain = createOsKeychainStore(); ctx.result.exitCode = await runCommand( { workflow, input: opts.input ?? [] }, { @@ -132,7 +135,8 @@ function registerRun(program: Command, ctx?: CommandContext): void { // Production wires durable run history (2.H) + the keychain-backed key resolver (2.C): the real CLI // persists to ~/.relavium/history.db and resolves keys via the OS keychain → env var. openRunStore: openHistoryStore, - providers: createProviderResolver(ctx.io.env, createOsKeychainStore()), + providers: createProviderResolver(ctx.io.env, keychain), + mcpSecretResolver: createMcpSecretResolver(ctx.io.env, keychain), }, ); }); @@ -158,12 +162,14 @@ function registerChat(program: Command, ctx?: CommandContext): void { } chat.action(async (opts: { agent?: string }) => { + const keychain = createOsKeychainStore(); ctx.result.exitCode = await chatCommand( { agent: opts.agent }, { io: ctx.io, global: ctx.global, - providers: createProviderResolver(ctx.io.env, createOsKeychainStore()), + providers: createProviderResolver(ctx.io.env, keychain), + mcpSecretResolver: createMcpSecretResolver(ctx.io.env, keychain), openSessionStore, drive: selectChatDriver, }, @@ -192,12 +198,14 @@ function registerChatResume(program: Command, ctx?: CommandContext): void { } chatResume.action(async (sessionId: string) => { + const keychain = createOsKeychainStore(); ctx.result.exitCode = await chatResumeCommand( { sessionId }, { io: ctx.io, global: ctx.global, - providers: createProviderResolver(ctx.io.env, createOsKeychainStore()), + providers: createProviderResolver(ctx.io.env, keychain), + mcpSecretResolver: createMcpSecretResolver(ctx.io.env, keychain), openSessionStore, drive: selectChatDriver, }, @@ -288,6 +296,7 @@ function registerAgent(program: Command, ctx?: CommandContext): void { } run.action(async (agentRef: string, opts: { input?: readonly string[]; fixture?: string }) => { + const keychain = createOsKeychainStore(); ctx.result.exitCode = await agentRunCommand( { agent: agentRef, @@ -298,7 +307,8 @@ function registerAgent(program: Command, ctx?: CommandContext): void { io: ctx.io, global: ctx.global, // A non-fixture run resolves keys via the OS keychain → env var (2.C), like `run`/`chat`. - providers: createProviderResolver(ctx.io.env, createOsKeychainStore()), + providers: createProviderResolver(ctx.io.env, keychain), + mcpSecretResolver: createMcpSecretResolver(ctx.io.env, keychain), }, ); }); diff --git a/apps/cli/src/engine/mcp-servers.test.ts b/apps/cli/src/engine/mcp-servers.test.ts index 9bcc26fa..8e95bb93 100644 --- a/apps/cli/src/engine/mcp-servers.test.ts +++ b/apps/cli/src/engine/mcp-servers.test.ts @@ -3,9 +3,10 @@ import { McpError, type McpClient, type McpServerConfig } from '@relavium/mcp'; import type { Agent, McpServerRef } from '@relavium/shared'; import { describe, expect, it } from 'vitest'; -import { isCliError } from '../process/errors.js'; +import { CliError, isCliError } from '../process/errors.js'; import { captureIo } from '../test-support.js'; import { + buildChildEnv, connectAgentMcp, connectWorkflowMcp, resolveStdioServerConfigs, @@ -72,7 +73,7 @@ describe('resolveStdioServerConfigs', () => { expect(() => resolveStdioServerConfigs([bad], '/work')).toThrow(/requires a 'command'/); }); - it('rejects an env value carrying a {{…}} marker (secret interpolation is a Step-4 follow-up)', () => { + it('rejects an env value carrying a {{…}} marker when NO secret resolver is wired', () => { try { resolveStdioServerConfigs([stdioRef({ env: { TOKEN: '{{secrets.gh}}' } })], '/work'); expect.unreachable('a {{…}} env value must throw'); @@ -91,6 +92,46 @@ describe('resolveStdioServerConfigs', () => { }); }); +describe('buildChildEnv (secret interpolation, 2.R Step 4)', () => { + it('resolves {{secrets.}} into the child env value via the resolver (whole + embedded)', () => { + const env = buildChildEnv( + 'fs', + { TOKEN: '{{secrets.gh}}', AUTH: 'Bearer {{ secrets.gh }}' }, + (name) => (name === 'gh' ? 'ghp_resolved' : 'OTHER'), + ); + expect(env).toEqual({ TOKEN: 'ghp_resolved', AUTH: 'Bearer ghp_resolved' }); + }); + + it('passes a literal env value through unchanged', () => { + expect(buildChildEnv('fs', { LOG: 'debug' }, () => 'x')).toEqual({ LOG: 'debug' }); + }); + + it('propagates the resolver fail-closed throw (a missing secret fails the build, never a literal)', () => { + expect(() => + buildChildEnv('fs', { TOKEN: '{{secrets.missing}}' }, () => { + throw new CliError('invalid_invocation', "MCP secret 'missing' is not set"); + }), + ).toThrow(/is not set/); + }); + + it('rejects a non-secret interpolation (only {{secrets.}} is supported), even with a resolver', () => { + try { + buildChildEnv('fs', { HOST: '{{env.HOSTNAME}}' }, () => 'x'); + expect.unreachable('an unsupported interpolation must throw'); + } catch (err) { + expect(isCliError(err) && err.code).toBe('invalid_invocation'); + expect((err as Error).message).toContain('HOST'); // names the key + expect((err as Error).message).toContain('only {{secrets.}}'); + } + }); + + it('rejects a {{secrets.…}} when no resolver is wired (the value is never passed literally)', () => { + expect(() => buildChildEnv('fs', { TOKEN: '{{secrets.gh}}' })).toThrow( + /unsupported interpolation/, + ); + }); +}); + describe('connectAgentMcp', () => { it('returns undefined when the agent declares no servers (no client, nothing to tear down)', async () => { const client = await connectAgentMcp(undefined, { cwd: '/work' }); diff --git a/apps/cli/src/engine/mcp-servers.ts b/apps/cli/src/engine/mcp-servers.ts index 9cc02873..e242259c 100644 --- a/apps/cli/src/engine/mcp-servers.ts +++ b/apps/cli/src/engine/mcp-servers.ts @@ -12,6 +12,7 @@ import type { Agent, AgentRef, McpServerRef } from '@relavium/shared'; import { CliError } from '../process/errors.js'; import type { CliIo } from '../process/io.js'; import { sanitizeInline } from '../render/tui/chat-projection.js'; +import type { McpSecretResolver } from '../secrets/mcp-secret.js'; /** * Resolve an agent's inline `mcp_servers` into a live {@link McpClient} (2.R Step 3 — CLI host wiring). This is @@ -33,6 +34,11 @@ export interface ConnectAgentMcpOptions { readonly cwd: string; /** Injectable connect-all (tests pass a fake that never spawns); defaults to the real `startMcpClient`. */ readonly startMcpClient?: (servers: readonly McpServerConfig[]) => Promise; + /** + * Resolve a `{{secrets.}}` placeholder in a server `env` value (2.R Step 4, ADR-0052 §6). When absent, + * any `{{…}}` in an `env` value is rejected loud (a placeholder is never passed to the child as a literal). + */ + readonly resolveSecret?: McpSecretResolver; } /** @@ -42,6 +48,7 @@ export interface ConnectAgentMcpOptions { export function resolveStdioServerConfigs( mcpServers: readonly McpServerRef[] | undefined, cwd: string, + resolveSecret?: McpSecretResolver, ): McpServerConfig[] { const configs: McpServerConfig[] = []; for (const ref of mcpServers ?? []) { @@ -61,7 +68,7 @@ export function resolveStdioServerConfigs( ); } const command = ref.command; - const env = buildChildEnv(ref.id, ref.env); + const env = buildChildEnv(ref.id, ref.env, resolveSecret); configs.push({ id: ref.id, ...(ref.tools_allowlist === undefined ? {} : { toolsAllowlist: ref.tools_allowlist }), @@ -88,7 +95,7 @@ export async function connectAgentMcp( mcpServers: readonly McpServerRef[] | undefined, opts: ConnectAgentMcpOptions, ): Promise { - const configs = resolveStdioServerConfigs(mcpServers, opts.cwd); + const configs = resolveStdioServerConfigs(mcpServers, opts.cwd, opts.resolveSecret); if (configs.length === 0) return undefined; return startMcpClientFailLoud(configs, opts.startMcpClient); } @@ -115,25 +122,40 @@ async function startMcpClientFailLoud( } } +/** Matches a `{{secrets.}}` placeholder (tolerant of inner whitespace) — the ONLY supported env interpolation. */ +const SECRET_PLACEHOLDER = /\{\{\s*secrets\.([A-Za-z0-9._-]+)\s*\}\}/g; + /** - * Build the child env for a stdio server from its declared `env` (verbatim for now). Rejects any value carrying - * a `{{…}}` interpolation marker: `{{secrets.*}}` resolution is the Step-4 follow-up (ADR-0052 §6), and passing - * an unresolved placeholder to the server as a literal is a silent-misconfig footgun, so it fails loud instead. + * Build the child env for a stdio server from its declared `env`, resolving `{{secrets.}}` placeholders + * (2.R Step 4, ADR-0052 §6) through the injected {@link McpSecretResolver} (keychain `mcp-secret:` → + * `RELAVIUM_MCP_` → fail-closed). The resolved value is injected ONLY here, into the explicit child env at + * spawn — never a committed file, a log, an event, or `--json`. Any **other** `{{…}}` (or any `{{` left when no + * resolver is wired) is rejected loud, so an unsupported/unresolved placeholder is never passed as a literal. + * + * Exported for a focused unit test of the interpolation/fail-closed behavior (the resolved value is otherwise + * hidden inside the spawn closure of {@link resolveStdioServerConfigs}). */ -function buildChildEnv( +export function buildChildEnv( serverId: string, declared: Readonly> | undefined, + resolveSecret?: McpSecretResolver, ): Record { const env: Record = {}; for (const [key, value] of Object.entries(declared ?? {})) { - if (value.includes('{{')) { + const resolved = + resolveSecret === undefined + ? value + : value.replace(SECRET_PLACEHOLDER, (_match, name: string) => resolveSecret(name)); + if (resolved.includes('{{')) { + // A leftover `{{` is an unsupported interpolation (e.g. `{{env.X}}`/`{{ctx.Y}}`), or a `{{secrets.…}}` + // with no resolver wired — never pass a placeholder to the server as a literal. The KEY is named, never + // the value (a resolved secret must not surface). throw new CliError( 'invalid_invocation', - `MCP server '${serverId}': env interpolation (e.g. {{secrets.…}}) in '${key}' is not wired yet. ` + - `Set a literal value for now, or omit it.`, + `MCP server '${serverId}': unsupported interpolation in env '${key}' — only {{secrets.}} is supported.`, ); } - env[key] = value; + env[key] = resolved; } return env; } @@ -149,6 +171,8 @@ export interface WorkflowMcpRuntime { export interface ConnectWorkflowMcpOptions { readonly cwd: string; readonly startMcpClient?: (servers: readonly McpServerConfig[]) => Promise; + /** Resolve `{{secrets.}}` in a server `env` value (2.R Step 4, ADR-0052 §6); see {@link ConnectAgentMcpOptions}. */ + readonly resolveSecret?: McpSecretResolver; } /** @@ -186,7 +210,7 @@ export async function connectWorkflowMcp( } if (byId.size === 0) return undefined; - const configs = resolveStdioServerConfigs([...byId.values()], opts.cwd); + const configs = resolveStdioServerConfigs([...byId.values()], opts.cwd, opts.resolveSecret); const client = await startMcpClientFailLoud(configs, opts.startMcpClient); // Augment each inline agent's grant with ONLY its own servers' discovered ids (a `$ref` entry passes through). diff --git a/apps/cli/src/secrets/mcp-secret.test.ts b/apps/cli/src/secrets/mcp-secret.test.ts new file mode 100644 index 00000000..eaabf11f --- /dev/null +++ b/apps/cli/src/secrets/mcp-secret.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; + +import { isCliError } from '../process/errors.js'; +import { KeychainUnavailableError, type KeychainStore } from './keychain.js'; +import { createMcpSecretResolver, mcpSecretAccount, mcpSecretEnvVar } from './mcp-secret.js'; + +/** An in-memory keychain fake seeded with the given accounts; never touches the real OS keychain. */ +function fakeKeychain(entries: Record = {}): KeychainStore & { reads: string[] } { + const reads: string[] = []; + return { + reads, + get: (account) => { + reads.push(account); + return entries[account] ?? null; + }, + set: () => undefined, + delete: () => false, + }; +} + +describe('mcpSecretAccount / mcpSecretEnvVar', () => { + it('derives the isolated keychain account and the env-var fallback name', () => { + expect(mcpSecretAccount('github-token')).toBe('mcp-secret:github-token'); + expect(mcpSecretEnvVar('github-token')).toBe('RELAVIUM_MCP_GITHUB_TOKEN'); + expect(mcpSecretEnvVar('gh.api')).toBe('RELAVIUM_MCP_GH_API'); // non-alphanumerics → underscore + }); +}); + +describe('createMcpSecretResolver', () => { + it('resolves from the isolated mcp-secret:* keychain account first', () => { + const keychain = fakeKeychain({ 'mcp-secret:gh': 'ghp_keychain' }); + const resolve = createMcpSecretResolver({}, keychain); + expect(resolve('gh')).toBe('ghp_keychain'); + expect(keychain.reads).toEqual(['mcp-secret:gh']); // read the isolated account, nothing else + }); + + it('falls back to RELAVIUM_MCP_ when the keychain has no entry', () => { + const keychain = fakeKeychain(); // empty + const resolve = createMcpSecretResolver({ RELAVIUM_MCP_GH: 'ghp_env' }, keychain); + expect(resolve('gh')).toBe('ghp_env'); + }); + + it('NEVER resolves a provider-key account or RELAVIUM__API_KEY (namespace isolation)', () => { + // The exfil scenario: an imported agent names `anthropic` hoping to read the provider key. The resolver + // reads ONLY `mcp-secret:anthropic` (empty) + `RELAVIUM_MCP_ANTHROPIC` — never `anthropic:default` nor + // `RELAVIUM_ANTHROPIC_API_KEY`. So the provider key cannot leak into a (possibly hostile) MCP server. + const keychain = fakeKeychain({ 'anthropic:default': 'sk-PROVIDER-SECRET' }); + const resolve = createMcpSecretResolver( + { RELAVIUM_ANTHROPIC_API_KEY: 'sk-PROVIDER-SECRET' }, + keychain, + ); + expect(() => resolve('anthropic')).toThrow(/not set/); // fail-closed — the provider key is unreachable + expect(keychain.reads).toEqual(['mcp-secret:anthropic']); // only the isolated account was queried + }); + + it('fails closed (typed exit-2 CliError, no secret value) when neither source has the secret', () => { + const resolve = createMcpSecretResolver({}, fakeKeychain()); + try { + resolve('missing'); + expect.unreachable('a missing secret must throw'); + } catch (err) { + expect(isCliError(err) && err.code).toBe('invalid_invocation'); + const msg = (err as Error).message; + expect(msg).toContain('mcp-secret:missing'); // names the keychain account + expect(msg).toContain('RELAVIUM_MCP_MISSING'); // and the env var + } + }); + + it('treats an unavailable keychain backend as absence — falls through to the env var', () => { + const throwing: KeychainStore = { + get: () => { + throw new KeychainUnavailableError('locked'); + }, + set: () => undefined, + delete: () => false, + }; + const resolve = createMcpSecretResolver({ RELAVIUM_MCP_GH: 'ghp_env' }, throwing); + expect(resolve('gh')).toBe('ghp_env'); // a locked keychain is not a fault — the env var serves + }); + + it('rejects an invalid secret name (defensive charset guard) before touching any source', () => { + const keychain = fakeKeychain(); + const resolve = createMcpSecretResolver({}, keychain); + expect(() => resolve('bad name')).toThrow(/invalid MCP secret name/); + expect(() => resolve('a/b')).toThrow(/invalid MCP secret name/); + expect(keychain.reads).toEqual([]); // never queried — fails on the name first + }); + + it('works env-only (no keychain) — the headless / CI path', () => { + const resolve = createMcpSecretResolver({ RELAVIUM_MCP_GH: 'ghp_env' }); + expect(resolve('gh')).toBe('ghp_env'); + }); +}); diff --git a/apps/cli/src/secrets/mcp-secret.ts b/apps/cli/src/secrets/mcp-secret.ts new file mode 100644 index 00000000..b2c19738 --- /dev/null +++ b/apps/cli/src/secrets/mcp-secret.ts @@ -0,0 +1,78 @@ +import { CliError } from '../process/errors.js'; +import { KeychainUnavailableError, type KeychainStore } from './keychain.js'; + +/** + * The **named-secret** resolver for inbound MCP servers (2.R Step 4, [ADR-0052](../../../../docs/decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §6). + * A `{{secrets.}}` placeholder in a server's `env` resolves through an **isolated** chain — + * OS keychain account `mcp-secret:` → `RELAVIUM_MCP_` env var → fail-closed — and the value is + * injected ONLY into the spawned child's environment, never into a committed file, a log, an event, or a + * `--json` line. + * + * **Namespace isolation is load-bearing.** This resolver reads ONLY the `mcp-secret:*` keychain namespace and + * `RELAVIUM_MCP_*` env vars — it can NEVER reach a provider-key account (`{providerId}:{keyId}`, e.g. + * `anthropic:default`) or `RELAVIUM__API_KEY`. Under the "a shared/imported workflow is the invite" + * distribution model, this closes an exfil path: a hostile `env: { LEAK: '{{secrets.anthropic}}' }` in an + * imported agent resolves `mcp-secret:anthropic` (a distinct, normally-empty account) — NOT the provider key — + * so it cannot leak the real key into a malicious MCP server's environment (keychain-and-secrets.md §Entry naming). + */ + +/** Resolve a named MCP secret to its value, or throw a typed, secret-free {@link CliError} when none is set. */ +export type McpSecretResolver = (name: string) => string; + +/** Valid secret-name charset — a conservative set that maps cleanly to both the keychain account and the env var. */ +const SECRET_NAME = /^[A-Za-z0-9._-]+$/; + +/** The keychain `account` for a named MCP secret — the **isolated** `mcp-secret:` namespace (ADR-0052 §6). */ +export function mcpSecretAccount(name: string): string { + return `mcp-secret:${name}`; +} + +/** + * The env-var fallback for a named MCP secret — `RELAVIUM_MCP_` (the name upper-cased, every non + * `[A-Z0-9]` char → `_`). Mirrors the provider-key `RELAVIUM__API_KEY` chain. NOTE: the normalization + * is lossy (e.g. `gh-api` and `gh.api` both map to `RELAVIUM_MCP_GH_API`) — the precise source is the keychain + * account; the env var is the headless/CI fallback. + */ +export function mcpSecretEnvVar(name: string): string { + return `RELAVIUM_MCP_${name.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`; +} + +/** + * Build an {@link McpSecretResolver} over the **isolated** `mcp-secret:*` chain — keychain `mcp-secret:` → + * `RELAVIUM_MCP_` env var → a fail-closed {@link CliError} (never echoing the secret value). The keychain + * is **optional** so tests / headless runs stay keychain-free (env-only), exactly like the provider resolver; an + * *unavailable* keychain backend (locked / no Secret Service) falls through to the env var (not a fault). + */ +export function createMcpSecretResolver( + env: Readonly> = process.env, + keychain?: KeychainStore, +): McpSecretResolver { + return (name) => { + if (!SECRET_NAME.test(name)) { + throw new CliError( + 'invalid_invocation', + `invalid MCP secret name '${name}' — use only letters, digits, '.', '_' or '-'.`, + ); + } + // 1. OS keychain — the isolated `mcp-secret:` account (NEVER a provider key). An unavailable backend + // falls through to the env var (the documented no-keychain path); any other error propagates. + if (keychain !== undefined) { + let fromKeychain: string | null = null; + try { + fromKeychain = keychain.get(mcpSecretAccount(name)); + } catch (err) { + if (!(err instanceof KeychainUnavailableError)) throw err; + } + if (fromKeychain !== null && fromKeychain !== '') return fromKeychain; + } + // 2. Env var — the headless / CI fallback (also namespace-scoped to `RELAVIUM_MCP_*`). + const fromEnv = env[mcpSecretEnvVar(name)]; + if (fromEnv !== undefined && fromEnv !== '') return fromEnv; + // 3. Fail-closed — a secret-free error naming both ways to provide it (never the value). + throw new CliError( + 'invalid_invocation', + `MCP secret '${name}' is not set — store it in the OS keychain (account ${mcpSecretAccount(name)}) ` + + `or set ${mcpSecretEnvVar(name)}.`, + ); + }; +} diff --git a/docs/reference/desktop/keychain-and-secrets.md b/docs/reference/desktop/keychain-and-secrets.md index 7593fa32..3e2c07bd 100644 --- a/docs/reference/desktop/keychain-and-secrets.md +++ b/docs/reference/desktop/keychain-and-secrets.md @@ -46,6 +46,13 @@ Each secret is stored as a **separate** keychain entry so individual keys can be The built-in web-search tool stores its key the same way under `account = search-provider` (see [../shared-core/built-in-tools.md](../shared-core/built-in-tools.md)). +**Inbound MCP named secrets** (2.R, [ADR-0052](../../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §6) live in a **separate, isolated namespace**: + +- `account` = `mcp-secret:` (e.g. `mcp-secret:github-token`) +- env-var fallback = `RELAVIUM_MCP_` (the name upper-cased, non-`[A-Z0-9]` → `_`), mirroring the provider `RELAVIUM__API_KEY` chain. + +A `{{secrets.}}` placeholder in an MCP server's `env` resolves **only** within this `mcp-secret:*` namespace (keychain → env var → fail-closed) and is injected **only** into the spawned server's child environment — never a committed YAML, a log, an event, or a `--json` line. The isolation is load-bearing: `{{secrets.*}}` can **never** reach a provider-key account (`{providerId}:{keyId}`) or `search-provider`, so a hostile imported workflow naming a *provider* key resolves an unrelated (normally-empty) `mcp-secret:*` account instead of the real key — closing the exfiltration path under the "a shared/imported workflow is the invite" distribution model. + The `llm_providers` table stores only an `api_key_keychain_ref` (the `account` identifier) — **never the key value** (see [database-schema.md](database-schema.md)). ## Encrypted-file fallback — deferred past v1.0 From bd786e3c08b179a20a523cf2a5567f555bcb5d0e Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 27 Jun 2026 12:03:43 +0300 Subject: [PATCH 08/24] =?UTF-8?q?fix(cli):=202.R=20Step=204a=20Opus-review?= =?UTF-8?q?=20=E2=80=94=20pre-substitution=20{{=20scan=20+=20no-leak=20+?= =?UTF-8?q?=20charset-guard=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three confirmed LOW findings from the security-focused review of 758aeb0 (the isolation invariant held — no leak/bypass found): - [LOW] `buildChildEnv` scanned the POST-substitution string for a leftover `{{`, so a legitimately-resolved secret VALUE containing `{{` was false-rejected (safe direction — no leak, but a valid secret was unusable). Now scan the DECLARED value with `{{secrets.}}` placeholders removed, THEN substitute — an unsupported `{{env.X}}` still fails loud; a resolved value with `{{` passes. + a regression test. - [LOW] No test proved the resolved value never reaches the event stream (the ADR-0052 §6 custody guarantee). The "resolvable secret" session-host test now runs a turn and asserts the sentinel appears in NONE of the drained events. - [LOW] The resolver's name-charset guard is unreachable from authored YAML (the placeholder regex pre-filters the charset), so a `{{secrets.bad name}}` is rejected as an unsupported interpolation before the resolver runs — added a test documenting which guard fires (the resolver guard stays defense-in-depth). Also refreshed the stale module-doc ("No secret interpolation yet"). The 4 refuted findings were correctly non-issues (the env-var fallback collision crosses no isolation boundary). lint/typecheck/test (658) + format green. Refs: ADR-0052 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/chat/session-host.test.ts | 13 +++++++++-- apps/cli/src/engine/mcp-servers.test.ts | 21 +++++++++++++++++ apps/cli/src/engine/mcp-servers.ts | 31 ++++++++++++++----------- 3 files changed, 50 insertions(+), 15 deletions(-) diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index 7240d3fa..f9585606 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -286,7 +286,7 @@ describe('buildChatSession + MCP host wiring (2.R)', () => { expect(started).toBe(false); // failed at env resolution, before any connect }); - it('threads the resolver: a resolvable secret lets the build proceed (no spurious fail-closed)', async () => { + it('threads the resolver: a resolvable secret lets the build proceed, and the value never reaches the stream', async () => { const conn: McpConnection = { listTools: () => Promise.resolve([]), callTool: () => Promise.resolve({ content: [], isError: false }), @@ -294,10 +294,19 @@ describe('buildChatSession + MCP host wiring (2.R)', () => { }; const built = await build({ agentRef: writeMcpAgent([' env:', ' TOKEN: "{{secrets.gh}}"']), - mcpSecretResolver: createMcpSecretResolver({ RELAVIUM_MCP_GH: 'ghp_resolved' }), + providers: scriptedResolver([textTurn('done')]), + mcpSecretResolver: createMcpSecretResolver({ RELAVIUM_MCP_GH: 'ghp_SECRET_SENTINEL' }), startMcpClient: () => realStartMcpClient([{ id: 'fs', open: () => Promise.resolve(conn) }]), }); expect(built.closeMcp).toBeDefined(); // the secret resolved ⇒ the build proceeded to connect + + // The resolved value lives ONLY in the child-env passed to the spawn — it must NEVER surface on the session + // event stream (ADR-0052 §6 custody guarantee). Run a turn and assert the sentinel appears nowhere. + built.session.start(); + await built.session.sendMessage('go'); + built.session.cancel(); + const events = await drainHandle(built.handle.events); + expect(JSON.stringify(events)).not.toContain('ghp_SECRET_SENTINEL'); await built.closeMcp?.(); }); }); diff --git a/apps/cli/src/engine/mcp-servers.test.ts b/apps/cli/src/engine/mcp-servers.test.ts index 8e95bb93..94b2f7ac 100644 --- a/apps/cli/src/engine/mcp-servers.test.ts +++ b/apps/cli/src/engine/mcp-servers.test.ts @@ -130,6 +130,27 @@ describe('buildChildEnv (secret interpolation, 2.R Step 4)', () => { /unsupported interpolation/, ); }); + + it('does NOT false-reject a resolved secret whose VALUE itself contains "{{" (scan the pre-substitution value)', () => { + // A token like `a{{b` is a legitimate resolved value; the leftover-`{{` check must scan the DECLARED value + // (placeholders removed), not the substituted result — else a valid secret becomes unusable. + const env = buildChildEnv('fs', { TOKEN: '{{secrets.gh}}' }, () => 'a{{b}}c'); + expect(env).toEqual({ TOKEN: 'a{{b}}c' }); + }); + + it('rejects a malformed {{secrets …}} (invalid name) as an unsupported interpolation, before the resolver', () => { + // The placeholder regex pre-filters the name charset, so `{{secrets.bad name}}` never matches → it is left as + // a leftover `{{` and rejected as unsupported (the resolver's own charset guard is defense-in-depth for a + // direct/programmatic call). The resolver is therefore never invoked here. + let called = false; + expect(() => + buildChildEnv('fs', { TOKEN: '{{secrets.bad name}}' }, () => { + called = true; + return 'x'; + }), + ).toThrow(/unsupported interpolation/); + expect(called).toBe(false); + }); }); describe('connectAgentMcp', () => { diff --git a/apps/cli/src/engine/mcp-servers.ts b/apps/cli/src/engine/mcp-servers.ts index e242259c..7a1d9716 100644 --- a/apps/cli/src/engine/mcp-servers.ts +++ b/apps/cli/src/engine/mcp-servers.ts @@ -22,10 +22,11 @@ import type { McpSecretResolver } from '../secrets/mcp-secret.js'; * `node:child_process` stay fenced inside `@relavium/mcp`, and `packages/core` never sees either. * * **Stdio only for now.** A `sse`/`websocket` (network) server fails loud here — the network transports + their - * SSRF guard are the Step-4 follow-up ([ADR-0053](../../../docs/decisions/0053-mcp-network-transport-egress-security.md)), - * and silently dropping a declared server is the opposite of secure-by-default. **No secret interpolation yet.** - * `{{secrets.*}}` resolution into the child env is also Step 4 (ADR-0052 §6); until it lands an `env` value - * containing `{{` is **rejected loud** so a placeholder is never passed to the server as a literal string. + * SSRF guard are the Step-4c follow-up ([ADR-0053](../../../docs/decisions/0053-mcp-network-transport-egress-security.md)), + * and silently dropping a declared server is the opposite of secure-by-default. A `{{secrets.}}` in a + * server `env` value is resolved (2.R Step 4a, ADR-0052 §6) through the injected {@link McpSecretResolver}; any + * other `{{…}}` (or a `{{secrets}}` with no resolver wired) is **rejected loud** so a placeholder is never + * passed to the server as a literal string. */ /** Options for {@link connectAgentMcp} — the spawn working dir + an injectable client starter (tests). */ @@ -142,20 +143,24 @@ export function buildChildEnv( ): Record { const env: Record = {}; for (const [key, value] of Object.entries(declared ?? {})) { - const resolved = - resolveSecret === undefined - ? value - : value.replace(SECRET_PLACEHOLDER, (_match, name: string) => resolveSecret(name)); - if (resolved.includes('{{')) { - // A leftover `{{` is an unsupported interpolation (e.g. `{{env.X}}`/`{{ctx.Y}}`), or a `{{secrets.…}}` - // with no resolver wired — never pass a placeholder to the server as a literal. The KEY is named, never - // the value (a resolved secret must not surface). + // Detect an unsupported interpolation on the DECLARED value with the supported `{{secrets.}}` + // placeholders removed (NOT on the substituted result) — so a leftover `{{` is `{{env.X}}`/`{{ctx.Y}}`, a + // malformed `{{secrets …}}`, or a `{{secrets}}` with no resolver wired. Scanning the pre-substitution value + // avoids a false reject when a legitimately-resolved secret VALUE itself contains the substring `{{`. + const withoutSecretRefs = + resolveSecret === undefined ? value : value.replace(SECRET_PLACEHOLDER, ''); + if (withoutSecretRefs.includes('{{')) { + // Never pass a placeholder to the server as a literal. The KEY is named, never the value (a resolved + // secret must not surface), and never the resolved value either. throw new CliError( 'invalid_invocation', `MCP server '${serverId}': unsupported interpolation in env '${key}' — only {{secrets.}} is supported.`, ); } - env[key] = resolved; + env[key] = + resolveSecret === undefined + ? value + : value.replace(SECRET_PLACEHOLDER, (_match, name: string) => resolveSecret(name)); } return env; } From 86ca02d9fe3fd423e0aef4287b68b0c789c825a9 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 27 Jun 2026 12:12:47 +0300 Subject: [PATCH 09/24] =?UTF-8?q?test(cli):=202.R=20Step=204a=20Sonnet-rev?= =?UTF-8?q?iew=20=E2=80=94=20tie=20the=20secret=20custody=20chain=20throug?= =?UTF-8?q?h=20the=20real=20spawn=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One confirmed LOW: the session-host "no-leak" test was tautological — its injected fake `startMcpClient` discarded the secret-bearing configs, so the resolved sentinel never entered the observed path and the stream-clean assertion would pass regardless. The value→env and stream-clean halves of the ADR-0052 §6 custody guarantee were never tied through the real resolveStdioServerConfigs→open chain. - `resolveStdioServerConfigs` gains an injectable `openConnection` (defaults to the real `openStdioConnection`) so a test can observe the spawn spec without spawning a child. - A new mcp-servers test injects that spy, invokes the config's `open()`, and asserts the RESOLVED secret (`Bearer {{secrets.gh}}` → `Bearer ghp_SENTINEL`) reaches the spawn-spec `env` — the genuine resolver → buildChildEnv → spawn-boundary tie. In production that `spec.env` is the only place the value flows. - The session-host test's comment is corrected to claim only what it proves (event-stream cleanliness), pointing at the new tie for value→env. The other 4 raw findings were correctly refuted. lint/typecheck/test (659)/build + format green. Step 4a review loop complete. Refs: ADR-0052 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/chat/session-host.test.ts | 5 +++-- apps/cli/src/engine/mcp-servers.test.ts | 26 ++++++++++++++++++++++++- apps/cli/src/engine/mcp-servers.ts | 13 ++++++++++++- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index f9585606..cd89fe69 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -300,8 +300,9 @@ describe('buildChatSession + MCP host wiring (2.R)', () => { }); expect(built.closeMcp).toBeDefined(); // the secret resolved ⇒ the build proceeded to connect - // The resolved value lives ONLY in the child-env passed to the spawn — it must NEVER surface on the session - // event stream (ADR-0052 §6 custody guarantee). Run a turn and assert the sentinel appears nowhere. + // Complementary half of the ADR-0052 §6 custody guarantee: the session EVENT STREAM carries no secret. (The + // value→spawn-env tie is proven directly in mcp-servers.test.ts "carries the RESOLVED secret into the + // spawn-spec env"; the injected fake client here does not spawn, so this asserts only stream-cleanliness.) built.session.start(); await built.session.sendMessage('go'); built.session.cancel(); diff --git a/apps/cli/src/engine/mcp-servers.test.ts b/apps/cli/src/engine/mcp-servers.test.ts index 94b2f7ac..58bc33f1 100644 --- a/apps/cli/src/engine/mcp-servers.test.ts +++ b/apps/cli/src/engine/mcp-servers.test.ts @@ -1,5 +1,5 @@ import { parseWorkflow, type WorkflowDefinition } from '@relavium/core'; -import { McpError, type McpClient, type McpServerConfig } from '@relavium/mcp'; +import { McpError, type McpClient, type McpConnection, type McpServerConfig } from '@relavium/mcp'; import type { Agent, McpServerRef } from '@relavium/shared'; import { describe, expect, it } from 'vitest'; @@ -90,6 +90,30 @@ describe('resolveStdioServerConfigs', () => { resolveStdioServerConfigs([stdioRef({ env: { LOG_LEVEL: 'debug' } })], '/work'), ).not.toThrow(); }); + + it('carries the RESOLVED secret into the spawn-spec env (and only there) — ties resolver → buildChildEnv → open', () => { + // The genuine custody tie: inject a spy `openConnection`, invoke the config's `open()`, and assert the + // resolved value reached the spawn boundary's `env`. In production that spec.env is the ONLY place the value + // flows (sdk-stdio.ts `env: {...spec.env}`); nothing else observes it. + let capturedSpec: { command: string; env: Record; cwd?: string } | undefined; + const conn: McpConnection = { + listTools: () => Promise.resolve([]), + callTool: () => Promise.resolve({ content: [], isError: false }), + close: () => Promise.resolve(), + }; + const configs = resolveStdioServerConfigs( + [stdioRef({ env: { TOKEN: 'Bearer {{secrets.gh}}' } })], + '/work', + (name) => (name === 'gh' ? 'ghp_SENTINEL' : 'OTHER'), + (_serverId, spec) => { + capturedSpec = spec; + return Promise.resolve(conn); + }, + ); + void configs[0]!.open(); // invoke the spawn closure → calls the spy with the built spec + expect(capturedSpec?.env).toEqual({ TOKEN: 'Bearer ghp_SENTINEL' }); // the resolved value reached the spawn env + expect(capturedSpec?.command).toBe('my-server'); + }); }); describe('buildChildEnv (secret interpolation, 2.R Step 4)', () => { diff --git a/apps/cli/src/engine/mcp-servers.ts b/apps/cli/src/engine/mcp-servers.ts index 7a1d9716..b8751f5e 100644 --- a/apps/cli/src/engine/mcp-servers.ts +++ b/apps/cli/src/engine/mcp-servers.ts @@ -5,7 +5,9 @@ import { startMcpClient as defaultStartMcpClient, type ManagerSkippedTool, type McpClient, + type McpConnection, type McpServerConfig, + type StdioServerSpec, } from '@relavium/mcp'; import type { Agent, AgentRef, McpServerRef } from '@relavium/shared'; @@ -42,14 +44,23 @@ export interface ConnectAgentMcpOptions { readonly resolveSecret?: McpSecretResolver; } +/** Open a stdio MCP connection from a spawn spec — the real {@link openStdioConnection}, or a test spy. */ +export type OpenStdioConnection = ( + serverId: string, + spec: StdioServerSpec, +) => Promise; + /** * Map an agent's inline `mcp_servers` to {@link McpServerConfig}s (stdio only). Throws a typed, exit-2 * {@link CliError} for a not-yet-wired transport or an unsupported (`{{…}}`) env value — never a silent skip. + * `openConnection` defaults to the real {@link openStdioConnection}; a test injects a spy to observe the spawn + * spec (the resolved-secret `env`) at the boundary without spawning a real child. */ export function resolveStdioServerConfigs( mcpServers: readonly McpServerRef[] | undefined, cwd: string, resolveSecret?: McpSecretResolver, + openConnection: OpenStdioConnection = openStdioConnection, ): McpServerConfig[] { const configs: McpServerConfig[] = []; for (const ref of mcpServers ?? []) { @@ -74,7 +85,7 @@ export function resolveStdioServerConfigs( id: ref.id, ...(ref.tools_allowlist === undefined ? {} : { toolsAllowlist: ref.tools_allowlist }), open: () => - openStdioConnection(ref.id, { + openConnection(ref.id, { command, env, cwd, From 210789f8373b81001e7e24ad5fd57b9d81e2f4f2 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 27 Jun 2026 12:32:03 +0300 Subject: [PATCH 10/24] =?UTF-8?q?feat(shared,cli):=202.R=20Step=204b=20?= =?UTF-8?q?=E2=80=94=20by-name=20`ref`=20MCP=20servers=20+=20transport-voc?= =?UTF-8?q?ab=20reconciliation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements ADR-0052 §5. Both schemas converge on `stdio | http | websocket` and an agent can reference a registered server by name. - `@relavium/shared`: - `McpTransportSchema` → `stdio | http | websocket` with `sse` a **deprecated alias** of `http` (Streamable HTTP); `http` requires an `http(s)` url, `websocket` a `ws(s)` url. - `McpServerRefSchema` gains the by-name `{ ref, tools_allowlist? }` branch (id/transport now optional), mutually exclusive with every inline connection field via the existing `superRefine`; the inline form still requires `id` + `transport`. The agent uniqueness key is now `ref ?? id`. - `McpServerRegistrationSchema` adds `websocket` (+ a ws(s) scheme guard) and exports the `McpServerRegistration` type. - CLI host: `resolveMcpServerRef` resolves a `{ ref }` entry to a self-contained inline server against the merged config `[[mcp_servers]]` registrations (id = the registration name, so two agents referencing it dedup to one connection); an unknown ref is fail-loud. Threaded through `connectAgentMcp` (chat) and `connectWorkflowMcp` (run); the per-agent grant keys on `ref ?? id`. `chat`/`chat-resume`/`agent run`/`run` pass `config.mcpServers`. - Docs reconciled: mcp-integration.md (the `McpServerRef` shape + transport vocab + the `ref` form), config-spec.md (`[[mcp_servers]]` vocab + ref linkage); deferred-tasks.md §5 entry removed (now done). Network transports themselves remain Step 4c — a non-stdio server (inline or via a ref) still fails loud in `resolveStdioServerConfigs`. Tests: schema (http transport, the ref form + mutual exclusivity, the inline-requires-id/transport guard, config websocket); host (`resolveMcpServerRef` resolve/passthrough/unknown, the chat + run ref e2e). lint/typecheck/test (408 shared + 665 cli)/build + format + seam green. Refs: ADR-0052 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/chat/session-host.ts | 17 +++- apps/cli/src/commands/agent-run.ts | 1 + apps/cli/src/commands/chat.ts | 2 + apps/cli/src/commands/run.ts | 1 + apps/cli/src/engine/mcp-servers.test.ts | 89 ++++++++++++++++++- apps/cli/src/engine/mcp-servers.ts | 86 +++++++++++++++--- docs/reference/contracts/config-spec.md | 10 ++- docs/reference/shared-core/mcp-integration.md | 20 +++-- docs/roadmap/deferred-tasks.md | 1 - packages/shared/src/agent.test.ts | 42 +++++++++ packages/shared/src/agent.ts | 83 +++++++++++++---- packages/shared/src/config.test.ts | 17 ++++ packages/shared/src/config.ts | 52 ++++++----- 13 files changed, 358 insertions(+), 63 deletions(-) diff --git a/apps/cli/src/chat/session-host.ts b/apps/cli/src/chat/session-host.ts index 1311c843..b09a7cae 100644 --- a/apps/cli/src/chat/session-host.ts +++ b/apps/cli/src/chat/session-host.ts @@ -15,7 +15,13 @@ import { type ToolHost, } from '@relavium/core'; import type { ManagerSkippedTool, McpClient, McpServerConfig } from '@relavium/mcp'; -import type { AgentSessionRecord, Budget, SessionContext, SessionMessage } from '@relavium/shared'; +import type { + AgentSessionRecord, + Budget, + McpServerRegistration, + SessionContext, + SessionMessage, +} from '@relavium/shared'; import type { ResolvedChatConfig } from '../config/resolve.js'; import { connectAgentMcp } from '../engine/mcp-servers.js'; @@ -64,6 +70,11 @@ export interface BuildChatSessionOptions { * is rejected loud. */ readonly mcpSecretResolver?: McpSecretResolver; + /** + * The merged config `[[mcp_servers]]` registrations (2.R Step 4b) — resolves a by-name `{ ref }` server + * entry on the bound agent. Absent ⇒ a `ref` entry fails loud. + */ + readonly mcpRegistrations?: readonly McpServerRegistration[]; /** * Session-scoped `{{ctx.*}}` variables (plaintext, NO secrets — agent-session-spec.md §Tools). `relavium * agent run --input k=v` (2.Q) populates these; a bare `chat` leaves them unset. @@ -194,6 +205,7 @@ export async function buildChatSession(opts: BuildChatSessionOptions): Promise Promise; /** Resolve `{{secrets.}}` in an MCP server `env` (2.R Step 4; see {@link BuildChatSessionOptions.mcpSecretResolver}). */ readonly mcpSecretResolver?: McpSecretResolver; + /** Config `[[mcp_servers]]` registrations for by-name `ref` resolution (2.R Step 4b; see {@link BuildChatSessionOptions.mcpRegistrations}). */ + readonly mcpRegistrations?: readonly McpServerRegistration[]; /** Sink for an `on_exceed: 'warn'` pre-egress budget warning (see {@link BuildChatSessionOptions}). */ readonly onBudgetWarning?: (warning: ChatBudgetWarning) => void; } @@ -307,6 +321,7 @@ export async function buildResumedChatSession( cwd: context.workingDir, ...(opts.startMcpClient === undefined ? {} : { startMcpClient: opts.startMcpClient }), ...(opts.mcpSecretResolver === undefined ? {} : { resolveSecret: opts.mcpSecretResolver }), + ...(opts.mcpRegistrations === undefined ? {} : { registrations: opts.mcpRegistrations }), }); try { diff --git a/apps/cli/src/commands/agent-run.ts b/apps/cli/src/commands/agent-run.ts index 58b2221d..e3f29a70 100644 --- a/apps/cli/src/commands/agent-run.ts +++ b/apps/cli/src/commands/agent-run.ts @@ -97,6 +97,7 @@ export async function agentRunCommand( uuid, providers, mcpSecretResolver: deps.mcpSecretResolver ?? createMcpSecretResolver(deps.io.env), + mcpRegistrations: config.mcpServers, }); // Render the live stream (NDJSON under --json, else the plain token/tool printer) and capture the turn // outcome — a classified turn failure completes with `session:turn_completed.error`, mapping to exit 1. diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index 436e04ca..a8b44c2f 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -147,6 +147,7 @@ export async function chatCommand(args: ChatCommandArgs, deps: ChatCommandDeps): uuid, providers, mcpSecretResolver: deps.mcpSecretResolver ?? createMcpSecretResolver(deps.io.env), + mcpRegistrations: config.mcpServers, onBudgetWarning: (warning) => deps.io.writeErr( `budget warning: ~${warning.thresholdPct}% of the ${warning.limitMicrocents}µ¢ cap reached\n`, @@ -224,6 +225,7 @@ export async function chatResumeCommand( now, providers, mcpSecretResolver: deps.mcpSecretResolver ?? createMcpSecretResolver(deps.io.env), + mcpRegistrations: config.mcpServers, onBudgetWarning: (warning) => deps.io.writeErr( `budget warning: ~${warning.thresholdPct}% of the ${warning.limitMicrocents}µ¢ cap reached\n`, diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index ce687e5f..0043f94d 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -165,6 +165,7 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr mcpRuntime = await connectWorkflowMcp(def, { cwd: deps.global.cwd, resolveSecret: deps.mcpSecretResolver ?? createMcpSecretResolver(deps.io.env), + registrations: config.mcpServers, ...(deps.startMcpClient === undefined ? {} : { startMcpClient: deps.startMcpClient }), }); if (mcpRuntime !== undefined) surfaceMcpSkipped(deps.io, mcpRuntime.client.skipped); diff --git a/apps/cli/src/engine/mcp-servers.test.ts b/apps/cli/src/engine/mcp-servers.test.ts index 58bc33f1..8500cbdf 100644 --- a/apps/cli/src/engine/mcp-servers.test.ts +++ b/apps/cli/src/engine/mcp-servers.test.ts @@ -1,6 +1,6 @@ import { parseWorkflow, type WorkflowDefinition } from '@relavium/core'; import { McpError, type McpClient, type McpConnection, type McpServerConfig } from '@relavium/mcp'; -import type { Agent, McpServerRef } from '@relavium/shared'; +import type { Agent, McpServerRef, McpServerRegistration } from '@relavium/shared'; import { describe, expect, it } from 'vitest'; import { CliError, isCliError } from '../process/errors.js'; @@ -9,6 +9,7 @@ import { buildChildEnv, connectAgentMcp, connectWorkflowMcp, + resolveMcpServerRef, resolveStdioServerConfigs, surfaceMcpSkipped, } from './mcp-servers.js'; @@ -217,6 +218,23 @@ describe('connectAgentMcp', () => { connectAgentMcp([stdioRef()], { cwd: '/work', startMcpClient: () => Promise.reject(boom) }), ).rejects.toBe(boom); }); + + it('resolves a by-name `ref` against the registrations before connecting (chat path, Step 4b)', async () => { + let seen: readonly McpServerConfig[] | undefined; + const registrations: McpServerRegistration[] = [ + { name: 'github', transport: 'stdio', command: 'gh-mcp' }, + ]; + const client = await connectAgentMcp([{ ref: 'github' }], { + cwd: '/work', + registrations, + startMcpClient: (servers) => { + seen = servers; + return Promise.resolve(fakeClient()); + }, + }); + expect(seen?.[0]?.id).toBe('github'); // the ref resolved to a stdio connection keyed by the registration name + expect(client).toBeDefined(); + }); }); describe('connectWorkflowMcp (run path)', () => { @@ -393,6 +411,75 @@ describe('connectWorkflowMcp (run path)', () => { expect(entries[0]).toEqual({ $ref: './reviewer.agent.yaml' }); // the $ref passes through untouched expect(agentOf(runtime!.workflow, 'scanner').tools).toEqual(['mcp_fs_read']); }); + + it('resolves a by-name `ref` against the config registrations and augments the agent grant (Step 4b)', async () => { + const def = wf( + ` - { id: scanner, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ ref: github }] }\n`, + ); + const registrations: McpServerRegistration[] = [ + { name: 'github', transport: 'stdio', command: 'gh-mcp' }, + ]; + let startedWith: readonly McpServerConfig[] | undefined; + const runtime = await connectWorkflowMcp(def, { + cwd: '/w', + registrations, + startMcpClient: (servers) => { + startedWith = servers; + return Promise.resolve( + fakeClient({ toolIdsByServer: new Map([['github', ['mcp_github_issue']]]) }), + ); + }, + }); + expect(startedWith?.[0]?.id).toBe('github'); // the ref resolved to a connection keyed by the registration name + // The agent's grant is augmented with the RESOLVED server's discovered tools (keyed by the ref name). + expect(agentOf(runtime!.workflow, 'scanner').tools).toEqual(['mcp_github_issue']); + }); + + it('fails loud when a by-name `ref` is not registered in [[mcp_servers]]', async () => { + const def = wf( + ` - { id: scanner, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ ref: unknown }] }\n`, + ); + await expect( + connectWorkflowMcp(def, { + cwd: '/w', + registrations: [], + startMcpClient: fakeStart(new Map()), + }), + ).rejects.toThrow(/ref 'unknown' is not registered/); + }); +}); + +describe('resolveMcpServerRef (by-name resolution, 2.R Step 4b)', () => { + const regs: McpServerRegistration[] = [ + { name: 'github', transport: 'stdio', command: 'gh-mcp', args: ['--stdio'], env: { GH: '1' } }, + ]; + + it('passes an inline entry through unchanged (same object)', () => { + const inline: McpServerRef = { id: 'fs', transport: 'stdio', command: 'x' }; + expect(resolveMcpServerRef(inline, regs)).toBe(inline); + }); + + it('resolves a { ref } to the registration connection (id = the registration name), carrying its allowlist', () => { + const resolved = resolveMcpServerRef({ ref: 'github', tools_allowlist: ['issue'] }, regs); + expect(resolved).toEqual({ + id: 'github', + transport: 'stdio', + command: 'gh-mcp', + args: ['--stdio'], + env: { GH: '1' }, + tools_allowlist: ['issue'], + }); + }); + + it('fails loud (typed exit-2 CliError) when the ref names no registration', () => { + try { + resolveMcpServerRef({ ref: 'nope' }, regs); + expect.unreachable('an unknown ref must throw'); + } catch (err) { + expect(isCliError(err) && err.code).toBe('invalid_invocation'); + expect((err as Error).message).toContain("ref 'nope' is not registered"); + } + }); }); describe('surfaceMcpSkipped', () => { diff --git a/apps/cli/src/engine/mcp-servers.ts b/apps/cli/src/engine/mcp-servers.ts index b8751f5e..7f0b4d2e 100644 --- a/apps/cli/src/engine/mcp-servers.ts +++ b/apps/cli/src/engine/mcp-servers.ts @@ -9,7 +9,7 @@ import { type McpServerConfig, type StdioServerSpec, } from '@relavium/mcp'; -import type { Agent, AgentRef, McpServerRef } from '@relavium/shared'; +import type { Agent, AgentRef, McpServerRef, McpServerRegistration } from '@relavium/shared'; import { CliError } from '../process/errors.js'; import type { CliIo } from '../process/io.js'; @@ -42,6 +42,45 @@ export interface ConnectAgentMcpOptions { * any `{{…}}` in an `env` value is rejected loud (a placeholder is never passed to the child as a literal). */ readonly resolveSecret?: McpSecretResolver; + /** + * The merged config `[[mcp_servers]]` registrations (2.R Step 4b, ADR-0052 §5) — used to resolve a by-name + * `{ ref: }` server entry to its self-contained connection. Absent ⇒ a `ref` entry fails loud. + */ + readonly registrations?: readonly McpServerRegistration[]; +} + +/** + * Resolve a by-name `{ ref: }` server entry to a self-contained inline {@link McpServerRef} + * against the merged config `[[mcp_servers]]` registrations (2.R Step 4b, ADR-0052 §5) — an inline entry passes + * through unchanged. The resolved server's routing/namespace `id` is the registration `name`, so two agents + * referencing the same registration dedup to one connection. An unknown `ref` is a fail-loud {@link CliError}. + */ +export function resolveMcpServerRef( + entry: McpServerRef, + registrations: readonly McpServerRegistration[], +): McpServerRef { + if (entry.ref === undefined) return entry; // inline — self-contained (the schema guarantees id + transport) + const reg = registrations.find((r) => r.name === entry.ref); + if (reg === undefined) { + throw new CliError( + 'invalid_invocation', + `MCP server ref '${entry.ref}' is not registered — add a [[mcp_servers]] entry named '${entry.ref}' to your config.`, + ); + } + return { + id: reg.name, + transport: reg.transport, + ...(reg.command === undefined ? {} : { command: reg.command }), + ...(reg.args === undefined ? {} : { args: reg.args }), + ...(reg.env === undefined ? {} : { env: reg.env }), + ...(reg.url === undefined ? {} : { url: reg.url }), + ...(entry.tools_allowlist === undefined ? {} : { tools_allowlist: entry.tools_allowlist }), + }; +} + +/** The routing/namespace id of an agent's mcp_servers ENTRY (before resolution) — the `ref` name or inline `id`. */ +function entryServerId(entry: McpServerRef): string | undefined { + return entry.ref ?? entry.id; } /** Open a stdio MCP connection from a spawn spec — the real {@link openStdioConnection}, or a test spy. */ @@ -64,10 +103,18 @@ export function resolveStdioServerConfigs( ): McpServerConfig[] { const configs: McpServerConfig[] = []; for (const ref of mcpServers ?? []) { + // A by-name `ref` must be resolved to inline (id + transport) before reaching here (resolveMcpServerRef). + if (ref.id === undefined) { + throw new CliError( + 'invalid_invocation', + `MCP server '${ref.ref ?? '?'}': a by-name reference could not be resolved to a connection.`, + ); + } + const serverId = ref.id; // capture the narrowed id so it survives into the deferred `open` closure if (ref.transport !== 'stdio') { throw new CliError( 'invalid_invocation', - `MCP server '${ref.id}': the '${ref.transport}' transport is not wired yet (stdio only for now). ` + + `MCP server '${serverId}': the '${ref.transport ?? '?'}' transport is not wired yet (stdio only for now). ` + `Network MCP transports land in a follow-up.`, ); } @@ -76,16 +123,16 @@ export function resolveStdioServerConfigs( if (ref.command === undefined) { throw new CliError( 'invalid_invocation', - `MCP server '${ref.id}': a 'stdio' transport requires a 'command'.`, + `MCP server '${serverId}': a 'stdio' transport requires a 'command'.`, ); } const command = ref.command; - const env = buildChildEnv(ref.id, ref.env, resolveSecret); + const env = buildChildEnv(serverId, ref.env, resolveSecret); configs.push({ - id: ref.id, + id: serverId, ...(ref.tools_allowlist === undefined ? {} : { toolsAllowlist: ref.tools_allowlist }), open: () => - openConnection(ref.id, { + openConnection(serverId, { command, env, cwd, @@ -107,7 +154,12 @@ export async function connectAgentMcp( mcpServers: readonly McpServerRef[] | undefined, opts: ConnectAgentMcpOptions, ): Promise { - const configs = resolveStdioServerConfigs(mcpServers, opts.cwd, opts.resolveSecret); + // Resolve any by-name `ref` entries to inline against the config registrations (Step 4b) BEFORE building the + // stdio configs — so the rest of the pipeline always sees a self-contained, inline server. + const inline = (mcpServers ?? []).map((entry) => + resolveMcpServerRef(entry, opts.registrations ?? []), + ); + const configs = resolveStdioServerConfigs(inline, opts.cwd, opts.resolveSecret); if (configs.length === 0) return undefined; return startMcpClientFailLoud(configs, opts.startMcpClient); } @@ -189,6 +241,8 @@ export interface ConnectWorkflowMcpOptions { readonly startMcpClient?: (servers: readonly McpServerConfig[]) => Promise; /** Resolve `{{secrets.}}` in a server `env` value (2.R Step 4, ADR-0052 §6); see {@link ConnectAgentMcpOptions}. */ readonly resolveSecret?: McpSecretResolver; + /** The merged config `[[mcp_servers]]` registrations (Step 4b) — resolves a by-name `ref` entry; see {@link ConnectAgentMcpOptions}. */ + readonly registrations?: readonly McpServerRegistration[]; } /** @@ -206,12 +260,17 @@ export async function connectWorkflowMcp( opts: ConnectWorkflowMcpOptions, ): Promise { const inlineAgents = (def.workflow.agents ?? []).filter(isInlineAgent); + const registrations = opts.registrations ?? []; - // Dedup the declared servers by id across agents: identical spec ⇒ one shared connection; same id with a - // conflicting spec ⇒ fail loud (the namespaced tool ids would otherwise collide across two different servers). + // Resolve each entry's by-name `ref` to inline (Step 4b), then dedup the servers by id across agents: identical + // spec ⇒ one shared connection; same id with a conflicting spec ⇒ fail loud (the namespaced tool ids would + // otherwise collide across two different servers). The resolved id (a registration name for a `ref`) is also + // the per-agent grant key below. const byId = new Map(); for (const agent of inlineAgents) { - for (const ref of agent.mcp_servers ?? []) { + for (const entry of agent.mcp_servers ?? []) { + const ref = resolveMcpServerRef(entry, registrations); + if (ref.id === undefined) continue; // unreachable (resolved refs carry an id); narrows for the Map key const existing = byId.get(ref.id); if (existing === undefined) { byId.set(ref.id, ref); @@ -250,7 +309,12 @@ function withWorkflowMcpGrant( agent: Agent, toolIdsByServer: ReadonlyMap, ): Agent { - const ids = (agent.mcp_servers ?? []).flatMap((server) => toolIdsByServer.get(server.id) ?? []); + // The grant key is the entry's server id — its `ref` registration name (Step 4b) or inline `id` — which is the + // same id `resolveMcpServerRef` assigned the connection, so `toolIdsByServer` is keyed by it. + const ids = (agent.mcp_servers ?? []).flatMap((server) => { + const serverId = entryServerId(server); + return serverId === undefined ? [] : (toolIdsByServer.get(serverId) ?? []); + }); if (ids.length === 0) return agent; return { ...agent, tools: [...new Set([...(agent.tools ?? []), ...ids])] }; } diff --git a/docs/reference/contracts/config-spec.md b/docs/reference/contracts/config-spec.md index 3325a783..4a39fc43 100644 --- a/docs/reference/contracts/config-spec.md +++ b/docs/reference/contracts/config-spec.md @@ -68,16 +68,20 @@ update_channel = "stable" # stable | beta default_model = "claude-sonnet-4-6" theme = "dark" -[[mcp_servers]] # repeatable +[[mcp_servers]] # repeatable — an agent references one by name via `ref:` (ADR-0052 §5) name = "filesystem" -transport = "stdio" # stdio | http +transport = "stdio" # stdio | http | websocket command = "npx -y @modelcontextprotocol/server-filesystem" args = ["--root", "~/projects"] autostart = true -# url = "http://localhost:4000" # for transport = http +# url = "https://host/mcp" # for transport = http (Streamable HTTP); a `websocket` server uses wss:// # env = { TOKEN = "..." } ``` +A `transport = "http"` / `"websocket"` registration requires a `url` (`http(s)` for `http`, `ws(s)` for +`websocket`); the url is SSRF-guarded and must not embed credentials. An agent consumes a registration with +`- ref: filesystem` (see [../shared-core/mcp-integration.md](../shared-core/mcp-integration.md)). + ## `project.toml` / `workspace.toml` (project) — keys ```toml diff --git a/docs/reference/shared-core/mcp-integration.md b/docs/reference/shared-core/mcp-integration.md index 6a61148d..d2a1cfc1 100644 --- a/docs/reference/shared-core/mcp-integration.md +++ b/docs/reference/shared-core/mcp-integration.md @@ -35,22 +35,28 @@ The `mcp_call` built-in tool is the lower-level path for invoking a registered s ### `McpServerRef` shape -In an agent (or workflow) declaration, each entry is an `McpServerRef`: +In an **agent** declaration (`agent.mcp_servers`), each entry is an `McpServerRef` — one of two mutually-exclusive forms ([ADR-0052](../../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §5): ```yaml mcp_servers: + # 1. INLINE — self-contained - id: github - transport: stdio # stdio | sse | websocket + transport: stdio # stdio | http | websocket (sse = deprecated alias of http) command: npx # stdio: the server binary args: ['-y', '@modelcontextprotocol/server-github'] env: # env vars injected into the server process - GITHUB_TOKEN: '{{secrets.github_token}}' + GITHUB_TOKEN: '{{secrets.github_token}}' # resolved from the isolated mcp-secret:* keychain (§6) - id: docs - transport: sse # sse / websocket use url instead of command - url: 'http://localhost:4000/mcp' + transport: http # http (Streamable HTTP) / websocket use `url` instead of `command` + url: 'https://docs.example/mcp' + # 2. BY-NAME `ref` — identity + connection come from a [[mcp_servers]] registration + - ref: shared-fs # mutually exclusive with id/transport/command/url/env + tools_allowlist: [read_file] # the only field allowed alongside `ref` ``` -Server **registrations** also live globally in `~/.relavium/config.toml` under repeatable `[[mcp_servers]]` entries (with `autostart`), so a server can be registered once and referenced by id from many agents. The merge of global and project-scoped servers follows the normal config resolution order — see [../contracts/config-spec.md](../contracts/config-spec.md). +The **transport vocabulary** is reconciled to the current MCP spec: `http` is the **Streamable HTTP** transport (the SDK's `StreamableHTTPClientTransport`); `sse` is a **deprecated alias** of `http` (the legacy HTTP+SSE transport, accepted for older servers); `websocket` uses a `wss://` url. A network `url` is SSRF-guarded (below). + +Server **registrations** also live globally in `~/.relavium/config.toml` under repeatable `[[mcp_servers]]` entries (with `autostart`), so a server can be registered once and referenced **by name** (`ref:`) from many agents. The merge of global and project-scoped servers follows the normal config resolution order — see [../contracts/config-spec.md](../contracts/config-spec.md). ### Tool discovery @@ -85,7 +91,7 @@ import { createMcpAdapter } from '@relavium/core/mcp'; const adapter = createMcpAdapter(engine, { workflows: ['security-review', 'refactor-agent'], - transport: 'stdio', // or 'sse' + transport: 'stdio', // or 'http' / 'websocket' (outbound is a later workstream) }); adapter.listen(); // registers each workflow as an MCP tool ``` diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 12b653a2..2504231d 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -44,7 +44,6 @@ Severity is the review's verified rating. Check an item off in the PR that resol - [ ] **MCP SDK network transport — upgrade to connect-by-validated-IP ([ADR-0053](../decisions/0053-mcp-network-transport-egress-security.md) §2).** 2.R ships **pre-connect host validation** as the floor for the `http` (Streamable HTTP) / `websocket` MCP transports — the `@modelcontextprotocol/sdk` opens its **own** socket, architecturally distinct from the `EgressCapability.fetch` hook above. When the SDK transport exposes an injectable `fetch`/dialer hook, upgrade to **connect-by-validated-IP**: resolve DNS → validate the IP against the shared range-block primitive → connect to that IP, re-validating on each redirect hop — closing the residual DNS-rebind window. Each MCP network mechanism gets a dedicated security-review pass when it lands. *(packages/mcp/src; ADR-0053 §2; ADR-0043 mechanism)* - [ ] **MCP `stdio` spawn — import-trust/consent gate + `npx` dependency pinning ([ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §2).** Spawning a declared `stdio` MCP server runs arbitrary local code / an `npx`-installed package. 2.R treats a server declared in the user's **own** committed YAML as author trust; the **imported/shared untrusted workflow** case is out of baseline scope. When the import/share path matures, gate the first spawn of a server from an untrusted-provenance `.relavium.yaml` behind explicit consent, and pin the `npx` package version/integrity for the built-in auto-install servers. *(packages/mcp/src; apps/cli; ADR-0052 §2; ADR-0029 trust model)* - [ ] **MCP host boundary — strip `McpConnectError.cause` from `--json` / event output (2.R Step 3, [ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §2).** The typed MCP errors' `message` is secret-free + surfaceable, but `.cause` (the wrapped SDK/spawn error) MAY carry the spawned command/args (never env/secret data). When the CLI host wires `@relavium/mcp` (Step 3+), a connect/discovery failure must surface as a typed `CliError` whose message is safe, and the host MUST NOT serialize `cause` verbatim into a `--json` envelope or a `RunEvent` — strip it at that boundary. The contract is documented in `@relavium/mcp` `errors.ts`. *(apps/cli; packages/mcp/src/errors.ts; 2.R Step 3)* -- [ ] **MCP transport-vocabulary reconciliation (`stdio | http | websocket`) ([ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §5).** ADR-0052 §5 binds both schemas to one vocabulary — `McpServerRefSchema` (agent) `stdio | sse | websocket` → `stdio | http | websocket` with `sse` a **deprecated alias** of `http`; `McpServerRegistrationSchema` (config) is already `stdio | http` — and updates `agent-yaml-spec.md`, `config-spec.md`, `mcp-integration.md` (which still document the legacy `stdio | sse | websocket`) to match. **2.R Step 3a wired only `stdio` (the CLI fails loud on any non-stdio transport), so the canonical spec is currently ahead of the agent schema.** This reconciliation lands with the network-transport sub-step (the by-name `ref` + `http`/`websocket` adapters), NOT the stdio host-wiring step — recorded here so the spec↔schema drift is tracked, not silent. *(packages/shared/src/agent.ts + config.ts; docs/reference/contracts/{agent-yaml-spec,config-spec}.md; docs/reference/shared-core/mcp-integration.md; ADR-0052 §5)* - [ ] **MCP follow-ups (non-security).** A durable cross-invocation **tool-list cache** (mcp-integration.md ~1h per-`(command,args)`, with a transport-covering key) — 2.R re-runs discovery per process ([ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §3); and a generalized **`SecretResolver`** seam beyond the 2.R `mcp-secret:*` keychain namespace ([ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §6); and reconciling the `types.ts` `ToolId` "register dynamically" comment to "host-side assembled" when 2.R touches `packages/core`; and **mid-call abort propagation** — the engine's `AbortSignalLike` is not forwarded to the in-flight MCP `tools/call` (the SDK transport wants a DOM `AbortSignal`), so a turn cancel tears the connection down but does not cancel an in-flight call (`@relavium/mcp` `manager.ts`). *(packages/mcp/src; packages/core; Phase-3)* - [ ] **Streaming media triad (`media_start`/`media_delta`/`media_end`) — host-deferred ([ADR-0046](../decisions/0046-inline-media-out-via-generate-streaming-triad-deferred.md) §4).** 1.AG Section B delivers inline media-out through the non-streaming `generate()` path (the in-flight diff --git a/packages/shared/src/agent.test.ts b/packages/shared/src/agent.test.ts index 3a0bd7e3..73829453 100644 --- a/packages/shared/src/agent.test.ts +++ b/packages/shared/src/agent.test.ts @@ -211,4 +211,46 @@ describe('McpServerRefSchema', () => { }).success, ).toBe(false); }); + + it('accepts the reconciled `http` (Streamable HTTP) transport with an http(s) url (ADR-0052 §5)', () => { + expect( + McpServerRefSchema.safeParse({ id: 'docs', transport: 'http', url: 'https://host/mcp' }) + .success, + ).toBe(true); + expect(McpServerRefSchema.safeParse({ id: 'docs', transport: 'http' }).success).toBe(false); // needs url + expect( + McpServerRefSchema.safeParse({ id: 'docs', transport: 'http', url: 'wss://host/mcp' }) + .success, + ).toBe(false); // http → http(s), not ws(s) + }); + + describe('by-name `ref` form (ADR-0052 §5)', () => { + it('accepts a bare { ref } and { ref, tools_allowlist } (the registration provides the connection)', () => { + expect(McpServerRefSchema.safeParse({ ref: 'github' }).success).toBe(true); + expect( + McpServerRefSchema.safeParse({ ref: 'github', tools_allowlist: ['create_issue'] }).success, + ).toBe(true); + }); + + it('rejects a `ref` mixed with ANY inline connection field (mutual exclusivity)', () => { + for (const inline of [ + { id: 'gh' }, + { transport: 'stdio' as const }, + { command: 'npx' }, + { args: ['-y', 'pkg'] }, + { env: { TOKEN: 'x' } }, + { url: 'https://h/mcp' }, + ]) { + expect(McpServerRefSchema.safeParse({ ref: 'github', ...inline }).success).toBe(false); + } + }); + + it('rejects an inline entry missing id or transport (a ref is the only way to omit them)', () => { + expect(McpServerRefSchema.safeParse({ transport: 'stdio', command: 'npx' }).success).toBe( + false, + ); // no id + expect(McpServerRefSchema.safeParse({ id: 'gh', command: 'npx' }).success).toBe(false); // no transport + expect(McpServerRefSchema.safeParse({}).success).toBe(false); // neither inline nor ref + }); + }); }); diff --git a/packages/shared/src/agent.ts b/packages/shared/src/agent.ts index 26b771f6..72189d3f 100644 --- a/packages/shared/src/agent.ts +++ b/packages/shared/src/agent.ts @@ -55,35 +55,77 @@ export const RetrySchema = z .strict(); export type Retry = z.infer; -/** Transport for an agent-declared MCP server (mcp-integration.md). */ -export const McpTransportSchema = z.enum(['stdio', 'sse', 'websocket']); +/** + * Transport for an agent-declared MCP server (mcp-integration.md), reconciled to the current MCP spec + * ([ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §5): `http` is the + * **Streamable HTTP** transport (the SDK's `StreamableHTTPClientTransport`); `sse` is a **deprecated alias** + * of `http` (the legacy HTTP+SSE transport, accepted for older servers). Both schemas (this + the config + * `McpServerRegistrationSchema`) converge on `stdio | http | websocket`. + */ +export const McpTransportSchema = z.enum(['stdio', 'http', 'websocket', 'sse']); /** Allowed URL schemes for a network MCP server — never file:/javascript:/etc. */ const SAFE_MCP_URL = /^(https?|wss?):\/\//i; +/** The inline connection fields a by-name `ref` must NOT carry (the registration provides them). */ +const INLINE_CONNECTION_FIELDS = ['id', 'transport', 'command', 'args', 'env', 'url'] as const; + /** - * A reference to an MCP server an agent consumes (`McpServerRef`). The transport - * dictates which connection field is required (mcp-integration.md): `stdio` needs a - * `command`; `sse`/`websocket` need a `url`. Enforced at the contract boundary so a - * mis-declared server is rejected at parse time, not at engine connect time. + * A reference to an MCP server an agent consumes (`McpServerRef`) — one of two mutually-exclusive forms + * ([ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §5): + * + * - **Inline**: a self-contained `{ id, transport, … }` where the transport dictates the required connection + * field — `stdio` needs a `command`; `http`/`sse`/`websocket` need a `url`. + * - **By-name `ref`**: `{ ref: , tools_allowlist? }` — identity AND connection come from a + * config `[[mcp_servers]]` registration (config.ts), so the inline `id`/`transport`/`command`/`url`/`env` + * fields are forbidden (the registration provides them). This realizes "register once, reference from many + * agents"; the host resolves the `ref` against the merged config at connect time. * - * Intentionally distinct from the **config-level** `McpServerRegistrationSchema` - * (config.ts), which *registers* a server by `name` with a `stdio | http` transport - * (config-spec.md). These are separate contracts (agent consumption vs global - * registration) and are kept apart on purpose rather than factored together. + * Enforced at the contract boundary so a mis-declared server is rejected at parse time, not at engine connect time. */ export const McpServerRefSchema = z .object({ - id: kebabIdSchema, - transport: McpTransportSchema, + id: kebabIdSchema.optional(), + transport: McpTransportSchema.optional(), command: z.string().optional(), args: z.array(z.string()).optional(), env: z.record(z.string(), z.string()).optional(), url: z.string().url().optional(), + ref: nonEmptyString.optional(), tools_allowlist: z.array(nonEmptyString).optional(), }) .strict() .superRefine((ref, ctx) => { + // The by-name `ref` form: identity + connection come from the registration; inline fields are forbidden. + if (ref.ref !== undefined) { + for (const field of INLINE_CONNECTION_FIELDS) { + if (ref[field] !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `'${field}' is not allowed with 'ref' — the [[mcp_servers]] registration provides it`, + path: [field], + }); + } + } + return; + } + // The inline form: `id` + `transport` are required (a `ref` is the only way to omit them). + if (ref.id === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "an inline server requires 'id' (or use 'ref' to reference a [[mcp_servers]] registration)", + path: ['id'], + }); + } + if (ref.transport === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "an inline server requires 'transport' (or use 'ref')", + path: ['transport'], + }); + return; // the per-transport checks below need a transport + } if (ref.transport === 'stdio' && !ref.command) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -91,7 +133,7 @@ export const McpServerRefSchema = z path: ['command'], }); } - if ((ref.transport === 'sse' || ref.transport === 'websocket') && !ref.url) { + if (ref.transport !== 'stdio' && !ref.url) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `url is required for the '${ref.transport}' transport`, @@ -99,16 +141,17 @@ export const McpServerRefSchema = z }); } if (ref.url !== undefined) { - // Per-transport scheme: `sse` is HTTP(S), `websocket` is WS(S) (stdio carries no url). + // Per-transport scheme: `http`/`sse` are HTTP(S), `websocket` is WS(S) (stdio carries no url). // This also blocks file:/javascript:/etc. as a side effect. let schemeOk: boolean; if (ref.transport === 'websocket') schemeOk = /^wss?:\/\//i.test(ref.url); - else if (ref.transport === 'sse') schemeOk = /^https?:\/\//i.test(ref.url); + else if (ref.transport === 'http' || ref.transport === 'sse') + schemeOk = /^https?:\/\//i.test(ref.url); else schemeOk = SAFE_MCP_URL.test(ref.url); if (!schemeOk) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `url scheme is invalid for the '${ref.transport}' transport (sse → http(s), websocket → ws(s))`, + message: `url scheme is invalid for the '${ref.transport}' transport (http/sse → http(s), websocket → ws(s))`, path: ['url'], }); } @@ -169,8 +212,12 @@ export const AgentSchema = z }) .strict() .superRefine((agent, ctx) => { - // MCP server ids must be unique within an agent (they namespace the registered tools). - const ids = (agent.mcp_servers ?? []).map((server) => server.id); + // MCP server identities must be unique within an agent (they namespace the registered tools). The identity + // is the inline `id` or, for a by-name entry, the `ref` registration name — both resolve to one server. (The + // `superRefine` on each ref guarantees exactly one is present; the filter only narrows the type for TS.) + const ids = (agent.mcp_servers ?? []) + .map((server) => server.ref ?? server.id) + .filter((identity): identity is string => identity !== undefined); const duplicates = findDuplicates(ids); if (duplicates.length > 0) { ctx.addIssue({ diff --git a/packages/shared/src/config.test.ts b/packages/shared/src/config.test.ts index 55444940..85d4538a 100644 --- a/packages/shared/src/config.test.ts +++ b/packages/shared/src/config.test.ts @@ -103,6 +103,23 @@ describe('config schemas', () => { ).toBe(false); // http needs url }); + it('accepts a `websocket` MCP registration with a ws(s) url, rejecting a non-ws scheme (ADR-0052 §5)', () => { + expect( + GlobalConfigSchema.safeParse({ + mcp_servers: [{ name: 'live', transport: 'websocket', url: 'wss://host/mcp' }], + }).success, + ).toBe(true); + expect( + GlobalConfigSchema.safeParse({ + mcp_servers: [{ name: 'live', transport: 'websocket', url: 'https://host/mcp' }], + }).success, + ).toBe(false); // websocket → ws(s), not http(s) + expect( + GlobalConfigSchema.safeParse({ mcp_servers: [{ name: 'live', transport: 'websocket' }] }) + .success, + ).toBe(false); // websocket needs url + }); + it('accepts project-scoped MCP registrations (merge with global)', () => { expect( ProjectConfigSchema.safeParse({ diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index 226beef7..9a684eba 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -14,17 +14,20 @@ export const UpdateChannelSchema = z.enum(['stable', 'beta']); /** Filesystem permission tier (built-in-tools.md) — derived from the shared tier vocabulary. */ export const FsScopeSchema = z.enum(FS_SCOPE_TIERS); -/** A registered `http` MCP server must use http(s) — never file:/javascript:/etc. */ +/** A registered network MCP server must use http(s) (`http`) or ws(s) (`websocket`) — never file:/javascript:/etc. */ const SAFE_HTTP_URL = /^https?:\/\//i; +const SAFE_WS_URL = /^wss?:\/\//i; /** - * An MCP server registration (`[[mcp_servers]]`). The transport dictates the required - * connection field: `stdio` needs a `command`; `http` needs a `url`. + * An MCP server registration (`[[mcp_servers]]`). The transport dictates the required connection field: + * `stdio` needs a `command`; `http` (Streamable HTTP) / `websocket` need a `url`. Reconciled with the agent + * `McpServerRefSchema` to one vocabulary — `stdio | http | websocket` + * ([ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §5). */ export const McpServerRegistrationSchema = z .object({ name: nonEmptyString, - transport: z.enum(['stdio', 'http']), + transport: z.enum(['stdio', 'http', 'websocket']), command: z.string().optional(), args: z.array(z.string()).optional(), autostart: z.boolean().optional(), @@ -41,30 +44,37 @@ export const McpServerRegistrationSchema = z path: ['command'], }); } - if (server.transport === 'http' && !server.url) { + if (server.transport !== 'stdio' && !server.url) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "url is required for the 'http' transport", + message: `url is required for the '${server.transport}' transport`, path: ['url'], }); } - // SSRF guard: a registered url must be http(s) — reject file:, javascript:, etc. - if (server.url !== undefined && !SAFE_HTTP_URL.test(server.url)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'url must use http or https', - path: ['url'], - }); - } - // Secret hygiene: no credentials embedded in a git-committed url. - if (server.url !== undefined && URL_HAS_CREDENTIALS.test(server.url)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'url must not embed credentials (user:pass@…) — use env/keychain auth', - path: ['url'], - }); + if (server.url !== undefined) { + // SSRF guard: a registered url must use the transport's scheme — reject file:, javascript:, etc. + const schemeOk = + server.transport === 'websocket' + ? SAFE_WS_URL.test(server.url) + : SAFE_HTTP_URL.test(server.url); + if (!schemeOk) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'url scheme is invalid (http → http(s), websocket → ws(s))', + path: ['url'], + }); + } + // Secret hygiene: no credentials embedded in a git-committed url. + if (URL_HAS_CREDENTIALS.test(server.url)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'url must not embed credentials (user:pass@…) — use env/keychain auth', + path: ['url'], + }); + } } }); +export type McpServerRegistration = z.infer; /** `~/.relavium/config.toml` — global preferences + MCP registrations. * `.strict()`: a typo in a committed config key fails loudly rather than being silently dropped — From bca8c68334206386e920160dcb0572fcbe3655dc Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 27 Jun 2026 12:45:04 +0300 Subject: [PATCH 11/24] =?UTF-8?q?fix(cli,docs):=202.R=20Step=204b=20Opus-r?= =?UTF-8?q?eview=20=E2=80=94=20sanitize=20the=20ref=20registration=20name?= =?UTF-8?q?=20into=20the=20namespace=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed MEDIUM (the isolation/fail-loud contract): a by-name `ref` resolved to `id = reg.name` RAW, but a `[[mcp_servers]]` name is a free `nonEmptyString` (spaces, `:`, `.`, `/`). The LLM namespace charset is `[A-Za-z0-9_-]`, so `namespacedId` rejected `mcp_{server}_{tool}` for any non-charset name and SILENTLY dropped every tool of that referenced server — the opposite of ADR-0052 §2's fail-loud / no-silent-capability-loss, and of §4/§5's promised "sanitized form of the registration name" (which was implemented nowhere). - `sanitizeServerSegment(name)` (mirrors `@relavium/mcp`'s tool-name charset) is applied to the resolved id in `resolveMcpServerRef` AND to the grant key in `entryServerId`, so the connection id, the dedup key, and the per-agent grant key all align on the same sanitized segment. An inline `id` is already kebab ⊂ the charset (a no-op); a sanitization collision fails closed via the manager's existing duplicate-id guard. The resolved id is host-internal + namespace-safe (may carry `_`), so it is deliberately NOT round-tripped through the kebab-only `McpServerRefSchema`. Plus the review's LOW items: - security-review.md: the MCP-URL bullet now names `http`/`websocket` (sse a deprecated alias), completing the §5 vocab reconciliation. - Tests: a non-charset registration name resolves to a sanitized id (no silent loss); two agents `{ ref: }` dedup to one connection (both granted); a `ref` to a `http` registration fails loud like an inline one. The 1 refuted finding was a non-issue. lint/typecheck/test (408 shared + 668 cli) + format green. Refs: ADR-0052 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/engine/mcp-servers.test.ts | 49 +++++++++++++++++++++++++ apps/cli/src/engine/mcp-servers.ts | 30 ++++++++++++--- docs/standards/security-review.md | 5 ++- 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/engine/mcp-servers.test.ts b/apps/cli/src/engine/mcp-servers.test.ts index 8500cbdf..4ab2ec5b 100644 --- a/apps/cli/src/engine/mcp-servers.test.ts +++ b/apps/cli/src/engine/mcp-servers.test.ts @@ -447,6 +447,43 @@ describe('connectWorkflowMcp (run path)', () => { }), ).rejects.toThrow(/ref 'unknown' is not registered/); }); + + it('dedups two agents each referencing the SAME registration by name to one connection (both granted)', async () => { + const def = wf( + [ + ' - { id: scanner, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ ref: github }] }', + ' - { id: writer, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ ref: github }] }', + '', + ].join('\n'), + ); + let startedWith: readonly McpServerConfig[] | undefined; + const runtime = await connectWorkflowMcp(def, { + cwd: '/w', + registrations: [{ name: 'github', transport: 'stdio', command: 'gh' }], + startMcpClient: (servers) => { + startedWith = servers; + return Promise.resolve( + fakeClient({ toolIdsByServer: new Map([['github', ['mcp_github_issue']]]) }), + ); + }, + }); + expect(startedWith).toHaveLength(1); // the two refs to `github` collapse to one connection + expect(agentOf(runtime!.workflow, 'scanner').tools).toEqual(['mcp_github_issue']); + expect(agentOf(runtime!.workflow, 'writer').tools).toEqual(['mcp_github_issue']); + }); + + it('fails loud when a `ref` resolves to a NON-stdio registration (network not wired until Step 4c)', async () => { + const def = wf( + ` - { id: scanner, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ ref: remote }] }\n`, + ); + await expect( + connectWorkflowMcp(def, { + cwd: '/w', + registrations: [{ name: 'remote', transport: 'http', url: 'https://h/mcp' }], + startMcpClient: fakeStart(new Map()), + }), + ).rejects.toThrow(/transport is not wired yet/); // identical to an inline non-stdio server + }); }); describe('resolveMcpServerRef (by-name resolution, 2.R Step 4b)', () => { @@ -480,6 +517,18 @@ describe('resolveMcpServerRef (by-name resolution, 2.R Step 4b)', () => { expect((err as Error).message).toContain("ref 'nope' is not registered"); } }); + + it('SANITIZES a non-charset registration name into a namespace-safe id (no silent total tool loss)', () => { + // A `[[mcp_servers]]` name is a free string (`github:prod`, `my server`); the namespace charset is + // `[A-Za-z0-9_-]`. The resolved id must be sanitized so `mcp_{server}_{tool}` stays valid and the server's + // tools are namespaced — NOT dropped at discovery (ADR-0052 §4/§5). + const messy: McpServerRegistration[] = [ + { name: 'github:prod', transport: 'stdio', command: 'gh' }, + { name: 'my server', transport: 'stdio', command: 'x' }, + ]; + expect(resolveMcpServerRef({ ref: 'github:prod' }, messy).id).toBe('github_prod'); + expect(resolveMcpServerRef({ ref: 'my server' }, messy).id).toBe('my_server'); + }); }); describe('surfaceMcpSkipped', () => { diff --git a/apps/cli/src/engine/mcp-servers.ts b/apps/cli/src/engine/mcp-servers.ts index 7f0b4d2e..88664d41 100644 --- a/apps/cli/src/engine/mcp-servers.ts +++ b/apps/cli/src/engine/mcp-servers.ts @@ -49,11 +49,27 @@ export interface ConnectAgentMcpOptions { readonly registrations?: readonly McpServerRegistration[]; } +/** + * Sanitize a registration `name` into a namespace-safe server segment for `mcp_{server}_{tool}` (ADR-0052 §4/§5 + * — "a sanitized form of the registration name"). A `[[mcp_servers]]` `name` is a free `nonEmptyString` (spaces, + * `:`, `.`, `/`, …), but the LLM-visible id charset is `[A-Za-z0-9_-]`; an UNsanitized segment would make + * `namespacedId` reject every tool of that server and silently drop them. Mirrors the tool-name sanitization in + * `@relavium/mcp`. (An inline `id` is already `kebab-case` ⊂ this charset, so sanitizing it is a no-op.) Two + * names that collapse to the same segment fail closed at discovery (the manager's duplicate-id/collision guards). + */ +function sanitizeServerSegment(name: string): string { + return name.replace(/[^A-Za-z0-9_-]/g, '_'); +} + /** * Resolve a by-name `{ ref: }` server entry to a self-contained inline {@link McpServerRef} * against the merged config `[[mcp_servers]]` registrations (2.R Step 4b, ADR-0052 §5) — an inline entry passes - * through unchanged. The resolved server's routing/namespace `id` is the registration `name`, so two agents - * referencing the same registration dedup to one connection. An unknown `ref` is a fail-loud {@link CliError}. + * through unchanged. The resolved server's routing/namespace `id` is the **sanitized** registration `name` + * ({@link sanitizeServerSegment}), so two agents referencing the same registration dedup to one connection and + * its tools namespace cleanly. An unknown `ref` is a fail-loud {@link CliError}. + * + * NOTE: the resolved `id` is host-internal and namespace-safe (may carry `_`/uppercase) — it is deliberately NOT + * re-validated through `McpServerRefSchema` (whose `id` is the stricter `kebabIdSchema`); it is never re-parsed. */ export function resolveMcpServerRef( entry: McpServerRef, @@ -68,7 +84,7 @@ export function resolveMcpServerRef( ); } return { - id: reg.name, + id: sanitizeServerSegment(reg.name), transport: reg.transport, ...(reg.command === undefined ? {} : { command: reg.command }), ...(reg.args === undefined ? {} : { args: reg.args }), @@ -78,9 +94,13 @@ export function resolveMcpServerRef( }; } -/** The routing/namespace id of an agent's mcp_servers ENTRY (before resolution) — the `ref` name or inline `id`. */ +/** + * The routing/namespace id of an agent's mcp_servers ENTRY (before resolution) — the **sanitized** `ref` + * registration name (matching {@link resolveMcpServerRef}, so the grant key aligns with the connection id) or the + * inline `id` (already charset-safe). + */ function entryServerId(entry: McpServerRef): string | undefined { - return entry.ref ?? entry.id; + return entry.ref !== undefined ? sanitizeServerSegment(entry.ref) : entry.id; } /** Open a stdio MCP connection from a spawn spec — the real {@link openStdioConnection}, or a test spy. */ diff --git a/docs/standards/security-review.md b/docs/standards/security-review.md index 5d41eda2..740c7b63 100644 --- a/docs/standards/security-review.md +++ b/docs/standards/security-review.md @@ -145,8 +145,9 @@ carrier fetched by `fetchMediaBytes`: binding rule — not a second home. See [ADR-0029](../decisions/0029-tool-policy-hardening.md) and [built-in-tools.md](../reference/shared-core/built-in-tools.md). -- **MCP server URLs.** *(Security tightening — ADR-0029(d).)* An MCP `sse`/`websocket` - server URL is a second egress path that **injects secrets** into headers, so leaving it +- **MCP server URLs.** *(Security tightening — ADR-0029(d); transport vocab reconciled by ADR-0052 §5.)* An MCP + `http` (Streamable HTTP) / `websocket` server URL (`sse` is a deprecated alias of `http`) + is a second egress path that **injects secrets** into headers, so leaving it scheme-checked-only while hardening `http_request` would be strictly worse. MCP server URLs run the **same** SSRF range-primitive (no second parser). See [mcp-integration.md](../reference/shared-core/mcp-integration.md) for the MCP contract From d1e492e3501c70ad63c6bcc8a6061eb7aa4d92d0 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 27 Jun 2026 12:55:20 +0300 Subject: [PATCH 12/24] =?UTF-8?q?fix(shared,docs):=202.R=20Step=204b=20Son?= =?UTF-8?q?net-review=20=E2=80=94=20finish=20the=20vocab=20reconciliation?= =?UTF-8?q?=20+=20tighten=20the=20schema/comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four confirmed (2 MEDIUM doc-drift the §5 reconciliation missed, 2 LOW): - [MEDIUM] mcp-integration.md:28 inbound-flow prose still said "SSE / WebSocket" → "Streamable HTTP / WebSocket", matching the reconciled example block + the schema (the §5 deferred-task removal is now honest). - [MEDIUM] mcp-integration.md:108 SSRF note cited `http://localhost:4000` examples this change had deleted → rewrite to describe a loopback/`localhost` url generically + name the `allow_local_endpoint` per-server opt-in (no dangling reference). - [LOW] The agent mcp_servers uniqueness comment overclaimed ("both resolve to one server"); tightened to state the schema catches EXACT duplicates and a host-side sanitization collision is caught fail-loud at discovery (ADR-0052 §4). - [LOW] `McpServerRefSchema` now rejects a stray `url` on a `stdio` transport (a mis-declared server fails at parse, secure-by-default) + a test. The 2 refuted findings were non-issues. lint/typecheck/test (409 shared + 668 cli) + format green. Step 4b review loop complete. Refs: ADR-0052 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/reference/shared-core/mcp-integration.md | 4 +-- packages/shared/src/agent.test.ts | 11 +++++++ packages/shared/src/agent.ts | 29 ++++++++++++++----- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/docs/reference/shared-core/mcp-integration.md b/docs/reference/shared-core/mcp-integration.md index d2a1cfc1..15f25093 100644 --- a/docs/reference/shared-core/mcp-integration.md +++ b/docs/reference/shared-core/mcp-integration.md @@ -25,7 +25,7 @@ flowchart LR An agent declares the MCP servers it uses in its `mcp_servers` list (see [../contracts/agent-yaml-spec.md](../contracts/agent-yaml-spec.md)). At agent startup the engine: -1. **Spawns** (stdio transport) or **connects to** (SSE / WebSocket) each declared MCP server. +1. **Spawns** (stdio transport) or **connects to** (Streamable HTTP / WebSocket) each declared MCP server. 2. Calls `tools/list` on each server and registers the discovered tools into the agent's tool namespace as `mcp_{server_id}_{tool_name}`. 3. Routes any tool call the agent makes to the correct MCP server using that registered mapping. 4. Streams results back as `agent:tool_result` events (see [../contracts/sse-event-schema.md](../contracts/sse-event-schema.md)). @@ -105,7 +105,7 @@ This is also how the `mcp_call` workflow **trigger** works: a workflow with `tri ## Security -- **MCP server URLs are SSRF-guarded ([ADR-0029](../../decisions/0029-tool-policy-hardening.md)).** A declared MCP `url` is validated against the **same** vetted range-block as a provider base URL and the `http_request` tool — private/loopback/link-local/metadata ranges (`127.0.0.0/8`, `::1`, `10/8`, `172.16/12`, `192.168/16`, `169.254/16`) are rejected, and remote hosts must use `https`/`wss`, **unless the user explicitly opts into a local endpoint**. The `http://localhost:4000` examples in this doc are exactly such a local endpoint and require that explicit opt-in. The one SSRF primitive is reused, never re-implemented — see [security-review.md](../../standards/security-review.md). +- **MCP server URLs are SSRF-guarded ([ADR-0029](../../decisions/0029-tool-policy-hardening.md)).** A declared MCP `url` is validated against the **same** vetted range-block as a provider base URL and the `http_request` tool — private/loopback/link-local/metadata ranges (`127.0.0.0/8`, `::1`, `10/8`, `172.16/12`, `192.168/16`, `169.254/16`) are rejected, and remote hosts must use `https`/`wss`, **unless the user explicitly opts into a local endpoint** (the per-server `allow_local_endpoint` flag, scoped to the declared `host:port`). A `http://localhost`/loopback `url` is exactly such a local endpoint and requires that explicit opt-in. The one SSRF primitive is reused, never re-implemented — see [security-review.md](../../standards/security-review.md). - MCP server credentials are injected from the secret store via the server's `env` (e.g. `{{secrets.github_token}}`) and are **never** written into the workflow file or any event payload. See [../desktop/keychain-and-secrets.md](../desktop/keychain-and-secrets.md). - Outbound (workflow-as-MCP) exposure is opt-in per workflow (only those listed in the adapter config are published). - All inbound MCP tool calls are schema-validated before dispatch, and tool inputs in events are sanitized — see [built-in-tools.md](built-in-tools.md) and [../contracts/sse-event-schema.md](../contracts/sse-event-schema.md). diff --git a/packages/shared/src/agent.test.ts b/packages/shared/src/agent.test.ts index 73829453..f1c5a055 100644 --- a/packages/shared/src/agent.test.ts +++ b/packages/shared/src/agent.test.ts @@ -154,6 +154,17 @@ describe('McpServerRefSchema', () => { expect(McpServerRefSchema.safeParse({ id: 'github', transport: 'stdio' }).success).toBe(false); }); + it('rejects a stray url on a stdio transport (a mis-declared server fails at parse)', () => { + expect( + McpServerRefSchema.safeParse({ + id: 'github', + transport: 'stdio', + command: 'npx', + url: 'https://host/mcp', + }).success, + ).toBe(false); + }); + it('requires url for sse / websocket transports', () => { expect( McpServerRefSchema.safeParse({ diff --git a/packages/shared/src/agent.ts b/packages/shared/src/agent.ts index 72189d3f..cda4fe94 100644 --- a/packages/shared/src/agent.ts +++ b/packages/shared/src/agent.ts @@ -126,12 +126,22 @@ export const McpServerRefSchema = z }); return; // the per-transport checks below need a transport } - if (ref.transport === 'stdio' && !ref.command) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "command is required for the 'stdio' transport", - path: ['command'], - }); + if (ref.transport === 'stdio') { + if (!ref.command) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "command is required for the 'stdio' transport", + path: ['command'], + }); + } + // A stdio server has no `url` — reject a stray one (a mis-declared server fails at parse, secure-by-default). + if (ref.url !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "url is not used by the 'stdio' transport", + path: ['url'], + }); + } } if (ref.transport !== 'stdio' && !ref.url) { ctx.addIssue({ @@ -213,8 +223,11 @@ export const AgentSchema = z .strict() .superRefine((agent, ctx) => { // MCP server identities must be unique within an agent (they namespace the registered tools). The identity - // is the inline `id` or, for a by-name entry, the `ref` registration name — both resolve to one server. (The - // `superRefine` on each ref guarantees exactly one is present; the filter only narrows the type for TS.) + // here is the EXACT inline `id` or by-name `ref` registration name. This catches the common exact-duplicate + // case at parse; a host-side *sanitization* collision (two distinct free-form registration names that map to + // the same namespace segment, e.g. `a.b` and `a b`) is NOT visible to the schema and is caught fail-loud at + // discovery instead (ADR-0052 §4 — the manager's duplicate-id/collision guards). (The `superRefine` on each + // ref guarantees exactly one of `ref`/`id` is present; the filter only narrows the type for TS.) const ids = (agent.mcp_servers ?? []) .map((server) => server.ref ?? server.id) .filter((identity): identity is string => identity !== undefined); From 70599a6d7fbf40ca67dca36b56e16d59d324c4c2 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 27 Jun 2026 13:18:03 +0300 Subject: [PATCH 13/24] =?UTF-8?q?feat(shared,cli,mcp):=202.R=20Step=204c?= =?UTF-8?q?=20=E2=80=94=20network=20MCP=20transports=20(http/sse/websocket?= =?UTF-8?q?)=20+=20the=20SSRF=20floor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the network MCP transports + ADR-0053's egress security, completing the three-transport lane (stdio + http + websocket; sse a deprecated alias). - `@relavium/mcp`: factor the shared Client+connection wrapper (`connectSdkTransport`, generalized from `StdioConnection`) and add three SDK-fenced network adapters — `openHttpConnection` (Streamable HTTP), `openSseConnection` (legacy HTTP+SSE, the `sse` alias), `openWebSocketConnection`. The SDK now lives in 4 fenced files; nothing leaks past the package. The websocket adapter requires a global `WebSocket` (Node 22+; the SDK has no `ws` dep) and fails LOUD with a clear typed error on older runtimes rather than adding a dependency. - CLI host: `resolveStdioServerConfigs` → `resolveServerConfigs` dispatches by transport. A network `url` passes `assertSafeNetworkEndpoint` — the SSRF pre-connect floor reusing the ONE shared `isPrivateOrLocalHost` range-block: a private/loopback/link-local host is rejected unless `allow_local_endpoint` is set (which also permits plaintext for THAT local endpoint), a remote host must be https/wss, and embedded credentials are always rejected. (The DNS-rebind connect-by-validated-IP dialer upgrade stays the tracked deferred follow-up.) - Schema: `allow_local_endpoint?` on `McpServerRefSchema` (forbidden with a by-name `ref`) + `McpServerRegistrationSchema`; `resolveMcpServerRef` carries it. Docs: mcp-integration.md + config-spec.md (the network examples + the local-endpoint opt-in). Tests: the transport dispatch (http/sse/websocket → the right opener); the SSRF floor matrix (private rejected, allow_local opt-in, remote-plaintext rejected regardless, embedded creds, malformed url); the websocket Node-22 guard; the network adapters' malformed-url fail-loud; a `ref` to a network registration through the gate. lint/typecheck/test (412 shared + 63 mcp + 675 cli)/build + bundle-closure + seam + format all green. Refs: ADR-0052, ADR-0053 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/engine/mcp-servers.test.ts | 148 +++++++++++--- apps/cli/src/engine/mcp-servers.ts | 185 +++++++++++++----- docs/reference/contracts/config-spec.md | 4 +- docs/reference/shared-core/mcp-integration.md | 6 +- packages/mcp/src/index.ts | 3 + packages/mcp/src/network-adapters.test.ts | 34 ++++ packages/mcp/src/sdk-http.ts | 40 ++++ packages/mcp/src/sdk-sse.ts | 33 ++++ packages/mcp/src/sdk-stdio.ts | 19 +- packages/mcp/src/sdk-websocket.ts | 43 ++++ packages/shared/src/agent.test.ts | 18 ++ packages/shared/src/agent.ts | 13 +- packages/shared/src/config.test.ts | 15 ++ packages/shared/src/config.ts | 2 + 14 files changed, 483 insertions(+), 80 deletions(-) create mode 100644 packages/mcp/src/network-adapters.test.ts create mode 100644 packages/mcp/src/sdk-http.ts create mode 100644 packages/mcp/src/sdk-sse.ts create mode 100644 packages/mcp/src/sdk-websocket.ts diff --git a/apps/cli/src/engine/mcp-servers.test.ts b/apps/cli/src/engine/mcp-servers.test.ts index 4ab2ec5b..ba793957 100644 --- a/apps/cli/src/engine/mcp-servers.test.ts +++ b/apps/cli/src/engine/mcp-servers.test.ts @@ -10,7 +10,7 @@ import { connectAgentMcp, connectWorkflowMcp, resolveMcpServerRef, - resolveStdioServerConfigs, + resolveServerConfigs, surfaceMcpSkipped, } from './mcp-servers.js'; @@ -33,9 +33,9 @@ const stdioRef = (over: Partial = {}): McpServerRef => ({ ...over, }); -describe('resolveStdioServerConfigs', () => { +describe('resolveServerConfigs', () => { it('maps a stdio ref to a config carrying its id + allowlist (open is a deferred spawn closure)', () => { - const configs = resolveStdioServerConfigs( + const configs = resolveServerConfigs( [stdioRef({ tools_allowlist: ['read', 'write'] })], '/work', ); @@ -46,37 +46,55 @@ describe('resolveStdioServerConfigs', () => { }); it('omits toolsAllowlist when the ref declares none (exactOptionalPropertyTypes — never an explicit undefined)', () => { - const configs = resolveStdioServerConfigs([stdioRef()], '/work'); + const configs = resolveServerConfigs([stdioRef()], '/work'); expect('toolsAllowlist' in configs[0]!).toBe(false); }); it('returns an empty list for undefined / empty mcp_servers', () => { - expect(resolveStdioServerConfigs(undefined, '/work')).toEqual([]); - expect(resolveStdioServerConfigs([], '/work')).toEqual([]); - }); - - it('rejects a network transport as a typed exit-2 CliError (stdio only until the Step-4 follow-up)', () => { - // `sse`/`websocket` are valid schema transports but not yet wired — fail loud, never a silent skip. - for (const transport of ['sse', 'websocket'] as const) { - try { - resolveStdioServerConfigs([{ id: 'x', transport, url: 'https://h/mcp' }], '/work'); - expect.unreachable('a network transport must throw'); - } catch (err) { - expect(isCliError(err) && err.code).toBe('invalid_invocation'); - expect((err as Error).message).toContain(transport); - } - } + expect(resolveServerConfigs(undefined, '/work')).toEqual([]); + expect(resolveServerConfigs([], '/work')).toEqual([]); + }); + + it('dispatches each network transport to its opener (http → Streamable HTTP, sse → legacy, websocket → ws)', async () => { + const calls: string[] = []; + const conn: McpConnection = { + listTools: () => Promise.resolve([]), + callTool: () => Promise.resolve({ content: [], isError: false }), + close: () => Promise.resolve(), + }; + const spy = + (label: string) => + (serverId: string, spec: { url: string }): Promise => { + calls.push(`${label}:${serverId}:${spec.url}`); + return Promise.resolve(conn); + }; + const configs = resolveServerConfigs( + [ + { id: 'a', transport: 'http', url: 'https://h/mcp' }, + { id: 'b', transport: 'sse', url: 'https://h/sse' }, + { id: 'c', transport: 'websocket', url: 'wss://h/ws' }, + ], + '/work', + undefined, + { http: spy('http'), sse: spy('sse'), websocket: spy('ws') }, + ); + await Promise.all(configs.map((c) => c.open())); + expect(calls.sort()).toEqual([ + 'http:a:https://h/mcp', + 'sse:b:https://h/sse', + 'ws:c:wss://h/ws', + ]); }); it('rejects a stdio ref with no command (defensive — the schema guarantees it, but the spawn must be total)', () => { // Construct the ref directly (bypassing the schema superRefine) to exercise the host-side guard. const bad: McpServerRef = { id: 'fs', transport: 'stdio' }; - expect(() => resolveStdioServerConfigs([bad], '/work')).toThrow(/requires a 'command'/); + expect(() => resolveServerConfigs([bad], '/work')).toThrow(/requires a 'command'/); }); it('rejects an env value carrying a {{…}} marker when NO secret resolver is wired', () => { try { - resolveStdioServerConfigs([stdioRef({ env: { TOKEN: '{{secrets.gh}}' } })], '/work'); + resolveServerConfigs([stdioRef({ env: { TOKEN: '{{secrets.gh}}' } })], '/work'); expect.unreachable('a {{…}} env value must throw'); } catch (err) { expect(isCliError(err) && err.code).toBe('invalid_invocation'); @@ -88,7 +106,7 @@ describe('resolveStdioServerConfigs', () => { it('accepts a literal env value (the common case)', () => { expect(() => - resolveStdioServerConfigs([stdioRef({ env: { LOG_LEVEL: 'debug' } })], '/work'), + resolveServerConfigs([stdioRef({ env: { LOG_LEVEL: 'debug' } })], '/work'), ).not.toThrow(); }); @@ -102,13 +120,15 @@ describe('resolveStdioServerConfigs', () => { callTool: () => Promise.resolve({ content: [], isError: false }), close: () => Promise.resolve(), }; - const configs = resolveStdioServerConfigs( + const configs = resolveServerConfigs( [stdioRef({ env: { TOKEN: 'Bearer {{secrets.gh}}' } })], '/work', (name) => (name === 'gh' ? 'ghp_SENTINEL' : 'OTHER'), - (_serverId, spec) => { - capturedSpec = spec; - return Promise.resolve(conn); + { + stdio: (_serverId, spec) => { + capturedSpec = spec; + return Promise.resolve(conn); + }, }, ); void configs[0]!.open(); // invoke the spawn closure → calls the spy with the built spec @@ -117,6 +137,63 @@ describe('resolveStdioServerConfigs', () => { }); }); +describe('SSRF floor (resolveServerConfigs network gate, 2.R Step 4c / ADR-0053)', () => { + const netRef = (over: Partial = {}): McpServerRef => ({ + id: 'n', + transport: 'http', + url: 'https://api.example/mcp', + ...over, + }); + // The gate runs synchronously at config build (before any connect), so a throw surfaces from resolveServerConfigs. + const build = (over: Partial = {}): McpServerConfig[] => + resolveServerConfigs([netRef(over)], '/work'); + + it('accepts a remote https/wss endpoint', () => { + expect(build({ url: 'https://api.example/mcp' })).toHaveLength(1); + expect(build({ transport: 'websocket', url: 'wss://api.example/ws' })).toHaveLength(1); + }); + + it('rejects a private/loopback/link-local host without allow_local_endpoint', () => { + for (const url of [ + 'http://127.0.0.1:4000/mcp', + 'http://localhost:4000/mcp', + 'http://10.0.0.5/mcp', + 'http://192.168.1.2/mcp', + 'http://169.254.169.254/latest', // the cloud metadata endpoint + 'http://[::1]/mcp', + ]) { + expect(() => build({ url })).toThrow(/private\/loopback/); + } + }); + + it('permits a private/loopback endpoint AND plaintext WITH allow_local_endpoint', () => { + expect(build({ url: 'http://localhost:4000/mcp', allow_local_endpoint: true })).toHaveLength(1); + expect( + build({ transport: 'websocket', url: 'ws://127.0.0.1:4000/ws', allow_local_endpoint: true }), + ).toHaveLength(1); + }); + + it('rejects a REMOTE plaintext endpoint regardless of allow_local_endpoint (the flag is local-only)', () => { + expect(() => build({ url: 'http://api.example/mcp' })).toThrow(/must use https\/wss/); + expect(() => build({ url: 'http://api.example/mcp', allow_local_endpoint: true })).toThrow( + /must use https\/wss/, + ); + expect(() => build({ transport: 'websocket', url: 'ws://api.example/ws' })).toThrow( + /must use https\/wss/, + ); + }); + + it('rejects an embedded-credentials url (the flag never relaxes it)', () => { + expect(() => + build({ url: 'https://user:pass@api.example/mcp', allow_local_endpoint: true }), + ).toThrow(/must not embed credentials/); + }); + + it('rejects a malformed url', () => { + expect(() => build({ url: 'not a url' })).toThrow(/malformed url/); + }); +}); + describe('buildChildEnv (secret interpolation, 2.R Step 4)', () => { it('resolves {{secrets.}} into the child env value via the resolver (whole + embedded)', () => { const env = buildChildEnv( @@ -472,17 +549,30 @@ describe('connectWorkflowMcp (run path)', () => { expect(agentOf(runtime!.workflow, 'writer').tools).toEqual(['mcp_github_issue']); }); - it('fails loud when a `ref` resolves to a NON-stdio registration (network not wired until Step 4c)', async () => { + it('resolves a `ref` to a remote https http registration through the SSRF gate (the network path is wired)', async () => { const def = wf( ` - { id: scanner, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ ref: remote }] }\n`, ); + const runtime = await connectWorkflowMcp(def, { + cwd: '/w', + registrations: [{ name: 'remote', transport: 'http', url: 'https://api.example/mcp' }], + startMcpClient: fakeStart(new Map([['remote', ['mcp_remote_x']]])), + }); + expect(runtime).toBeDefined(); // a remote https ref resolves + passes the SSRF floor (no fail-loud) + expect(agentOf(runtime!.workflow, 'scanner').tools).toEqual(['mcp_remote_x']); + }); + + it('fails loud at the SSRF floor when a `ref` resolves to a private http url without allow_local_endpoint', async () => { + const def = wf( + ` - { id: scanner, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ ref: local }] }\n`, + ); await expect( connectWorkflowMcp(def, { cwd: '/w', - registrations: [{ name: 'remote', transport: 'http', url: 'https://h/mcp' }], + registrations: [{ name: 'local', transport: 'http', url: 'http://127.0.0.1:4000/mcp' }], startMcpClient: fakeStart(new Map()), }), - ).rejects.toThrow(/transport is not wired yet/); // identical to an inline non-stdio server + ).rejects.toThrow(/private\/loopback/); }); }); diff --git a/apps/cli/src/engine/mcp-servers.ts b/apps/cli/src/engine/mcp-servers.ts index 88664d41..b5a250e3 100644 --- a/apps/cli/src/engine/mcp-servers.ts +++ b/apps/cli/src/engine/mcp-servers.ts @@ -1,15 +1,27 @@ import type { WorkflowDefinition } from '@relavium/core'; import { McpError, + openHttpConnection, + openSseConnection, openStdioConnection, + openWebSocketConnection, startMcpClient as defaultStartMcpClient, + type HttpServerSpec, type ManagerSkippedTool, type McpClient, type McpConnection, type McpServerConfig, + type SseServerSpec, type StdioServerSpec, + type WebSocketServerSpec, } from '@relavium/mcp'; -import type { Agent, AgentRef, McpServerRef, McpServerRegistration } from '@relavium/shared'; +import { + isPrivateOrLocalHost, + type Agent, + type AgentRef, + type McpServerRef, + type McpServerRegistration, +} from '@relavium/shared'; import { CliError } from '../process/errors.js'; import type { CliIo } from '../process/io.js'; @@ -17,18 +29,18 @@ import { sanitizeInline } from '../render/tui/chat-projection.js'; import type { McpSecretResolver } from '../secrets/mcp-secret.js'; /** - * Resolve an agent's inline `mcp_servers` into a live {@link McpClient} (2.R Step 3 — CLI host wiring). This is - * the Node-host arm that ADR-0052 §2 delegates to the host: it turns each declared **stdio** server into an - * {@link McpServerConfig} whose `open()` spawns + connects via `@relavium/mcp`'s SDK-fenced `openStdioConnection`, - * then hands the set to `startMcpClient` (fail-loud connect-all). Only Relavium shapes cross back — the SDK and - * `node:child_process` stay fenced inside `@relavium/mcp`, and `packages/core` never sees either. + * Resolve an agent's inline `mcp_servers` into a live {@link McpClient} (2.R — CLI host wiring). This is the + * Node-host arm that ADR-0052 §2 delegates to the host: it turns each declared server into an + * {@link McpServerConfig} whose `open()` spawns (`stdio`) or connects (`http`/`sse`/`websocket`) via + * `@relavium/mcp`'s SDK-fenced adapters, then hands the set to `startMcpClient` (fail-loud connect-all). Only + * Relavium shapes cross back — the SDK and `node:child_process` stay fenced inside `@relavium/mcp`, and + * `packages/core` never sees either. * - * **Stdio only for now.** A `sse`/`websocket` (network) server fails loud here — the network transports + their - * SSRF guard are the Step-4c follow-up ([ADR-0053](../../../docs/decisions/0053-mcp-network-transport-egress-security.md)), - * and silently dropping a declared server is the opposite of secure-by-default. A `{{secrets.}}` in a - * server `env` value is resolved (2.R Step 4a, ADR-0052 §6) through the injected {@link McpSecretResolver}; any - * other `{{…}}` (or a `{{secrets}}` with no resolver wired) is **rejected loud** so a placeholder is never - * passed to the server as a literal string. + * A **network** (`http`/`sse`/`websocket`) `url` passes the {@link assertSafeNetworkEndpoint} SSRF floor + * (ADR-0053) before connecting. A `{{secrets.}}` in a server `env` value is resolved (2.R Step 4a, + * ADR-0052 §6) through the injected {@link McpSecretResolver}; any other `{{…}}` (or a `{{secrets}}` with no + * resolver wired) is **rejected loud** so a placeholder is never passed to the server as a literal string. A + * by-name `ref` is resolved against the config registrations ({@link resolveMcpServerRef}). */ /** Options for {@link connectAgentMcp} — the spawn working dir + an injectable client starter (tests). */ @@ -90,6 +102,9 @@ export function resolveMcpServerRef( ...(reg.args === undefined ? {} : { args: reg.args }), ...(reg.env === undefined ? {} : { env: reg.env }), ...(reg.url === undefined ? {} : { url: reg.url }), + ...(reg.allow_local_endpoint === undefined + ? {} + : { allow_local_endpoint: reg.allow_local_endpoint }), ...(entry.tools_allowlist === undefined ? {} : { tools_allowlist: entry.tools_allowlist }), }; } @@ -110,57 +125,135 @@ export type OpenStdioConnection = ( ) => Promise; /** - * Map an agent's inline `mcp_servers` to {@link McpServerConfig}s (stdio only). Throws a typed, exit-2 - * {@link CliError} for a not-yet-wired transport or an unsupported (`{{…}}`) env value — never a silent skip. - * `openConnection` defaults to the real {@link openStdioConnection}; a test injects a spy to observe the spawn - * spec (the resolved-secret `env`) at the boundary without spawning a real child. + * Injectable transport openers — defaults are the real `@relavium/mcp` adapters; a test injects spies to + * observe the built spec (or assert the SSRF gate) without a real spawn/connect. + */ +export interface ServerOpeners { + readonly stdio?: OpenStdioConnection; + readonly http?: (serverId: string, spec: HttpServerSpec) => Promise; + readonly sse?: (serverId: string, spec: SseServerSpec) => Promise; + readonly websocket?: (serverId: string, spec: WebSocketServerSpec) => Promise; +} + +/** + * Map an agent's inline `mcp_servers` to {@link McpServerConfig}s, dispatching by transport — `stdio` spawns a + * child (the declared `env` with `{{secrets.*}}` resolved), and `http` (Streamable HTTP) / `sse` (legacy + * HTTP+SSE alias) / `websocket` open a network connection through the **SSRF gate** ({@link + * assertSafeNetworkEndpoint}). Throws a typed, exit-2 {@link CliError} for an unresolved ref, an unsupported + * (`{{…}}`) env, or an unsafe network endpoint — never a silent skip. A by-name `ref` must already be resolved + * to inline ({@link resolveMcpServerRef}). */ -export function resolveStdioServerConfigs( +export function resolveServerConfigs( mcpServers: readonly McpServerRef[] | undefined, cwd: string, resolveSecret?: McpSecretResolver, - openConnection: OpenStdioConnection = openStdioConnection, + openers: ServerOpeners = {}, ): McpServerConfig[] { + const openStdio = openers.stdio ?? openStdioConnection; + const openHttp = openers.http ?? openHttpConnection; + const openSse = openers.sse ?? openSseConnection; + const openWs = openers.websocket ?? openWebSocketConnection; const configs: McpServerConfig[] = []; for (const ref of mcpServers ?? []) { // A by-name `ref` must be resolved to inline (id + transport) before reaching here (resolveMcpServerRef). - if (ref.id === undefined) { + if (ref.id === undefined || ref.transport === undefined) { throw new CliError( 'invalid_invocation', - `MCP server '${ref.ref ?? '?'}': a by-name reference could not be resolved to a connection.`, + `MCP server '${ref.ref ?? ref.id ?? '?'}': a by-name reference could not be resolved to a connection.`, ); } const serverId = ref.id; // capture the narrowed id so it survives into the deferred `open` closure - if (ref.transport !== 'stdio') { + const allowlist = + ref.tools_allowlist === undefined ? {} : { toolsAllowlist: ref.tools_allowlist }; + + if (ref.transport === 'stdio') { + // The schema's `superRefine` already guarantees `command` for a stdio transport; re-assert so the spawn + // spec is total without a non-null assertion (a defensive, typed failure rather than an undefined spawn). + if (ref.command === undefined) { + throw new CliError( + 'invalid_invocation', + `MCP server '${serverId}': a 'stdio' transport requires a 'command'.`, + ); + } + const command = ref.command; + const env = buildChildEnv(serverId, ref.env, resolveSecret); + configs.push({ + id: serverId, + ...allowlist, + open: () => + openStdio(serverId, { + command, + env, + cwd, + ...(ref.args === undefined ? {} : { args: ref.args }), + }), + }); + continue; + } + + // Network transport (http | sse | websocket). The schema guarantees a `url`; re-assert defensively. + if (ref.url === undefined) { throw new CliError( 'invalid_invocation', - `MCP server '${serverId}': the '${ref.transport ?? '?'}' transport is not wired yet (stdio only for now). ` + - `Network MCP transports land in a follow-up.`, + `MCP server '${serverId}': the '${ref.transport}' transport requires a 'url'.`, ); } - // The schema's `superRefine` already guarantees `command` for a stdio transport; re-assert so the spawn - // spec is total without a non-null assertion (a defensive, typed failure rather than an undefined spawn). - if (ref.command === undefined) { + const url = ref.url; + const transport = ref.transport; + // The SSRF pre-connect floor — rejects a private/loopback/link-local host (unless opted in) and a plaintext + // remote (ADR-0053). The connect-by-validated-IP dialer upgrade (DNS-rebind) is the tracked follow-up. + assertSafeNetworkEndpoint(serverId, url, ref.allow_local_endpoint === true); + const open = + transport === 'websocket' + ? () => openWs(serverId, { url }) + : transport === 'sse' + ? () => openSse(serverId, { url }) + : () => openHttp(serverId, { url }); // 'http' (Streamable HTTP) + configs.push({ id: serverId, ...allowlist, open }); + } + return configs; +} + +/** + * The **SSRF pre-connect floor** for a network MCP `url` (2.R Step 4c, [ADR-0053](../../../docs/decisions/0053-mcp-network-transport-egress-security.md)). + * Reuses the ONE shared `isPrivateOrLocalHost` range-block primitive (never re-implemented). A private/loopback/ + * link-local/metadata host is rejected UNLESS `allow_local_endpoint` is set (which, for that local endpoint, + * also permits plaintext `http`/`ws` — a local-dev server is typically plaintext); a **remote** host must use + * `https`/`wss` regardless of the flag. The no-embedded-credentials check is enforced here too (the flag never + * relaxes it). This is the host-validated FLOOR; the SDK transport opens its own socket, so the DNS-rebind + * connect-by-validated-IP upgrade is a tracked follow-up (deferred-tasks.md). + */ +function assertSafeNetworkEndpoint(serverId: string, url: string, allowLocal: boolean): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new CliError('invalid_invocation', `MCP server '${serverId}': malformed url.`); + } + if (parsed.username !== '' || parsed.password !== '') { + throw new CliError( + 'invalid_invocation', + `MCP server '${serverId}': the url must not embed credentials (user:pass@…) — use env/keychain auth.`, + ); + } + const host = parsed.hostname.replace(/^\[/, '').replace(/\]$/, ''); + const isSecure = parsed.protocol === 'https:' || parsed.protocol === 'wss:'; + if (isPrivateOrLocalHost(host)) { + if (!allowLocal) { throw new CliError( 'invalid_invocation', - `MCP server '${serverId}': a 'stdio' transport requires a 'command'.`, + `MCP server '${serverId}': '${host}' is a private/loopback/link-local address. ` + + `Set 'allow_local_endpoint: true' on the server to permit a local MCP endpoint.`, ); } - const command = ref.command; - const env = buildChildEnv(serverId, ref.env, resolveSecret); - configs.push({ - id: serverId, - ...(ref.tools_allowlist === undefined ? {} : { toolsAllowlist: ref.tools_allowlist }), - open: () => - openConnection(serverId, { - command, - env, - cwd, - ...(ref.args === undefined ? {} : { args: ref.args }), - }), - }); + return; // a local endpoint with the explicit opt-in — plaintext is permitted for it (ADR-0053 §3). + } + if (!isSecure) { + throw new CliError( + 'invalid_invocation', + `MCP server '${serverId}': a remote MCP url must use https/wss (got '${parsed.protocol.replace(':', '')}').`, + ); } - return configs; } /** @@ -179,7 +272,7 @@ export async function connectAgentMcp( const inline = (mcpServers ?? []).map((entry) => resolveMcpServerRef(entry, opts.registrations ?? []), ); - const configs = resolveStdioServerConfigs(inline, opts.cwd, opts.resolveSecret); + const configs = resolveServerConfigs(inline, opts.cwd, opts.resolveSecret); if (configs.length === 0) return undefined; return startMcpClientFailLoud(configs, opts.startMcpClient); } @@ -217,7 +310,7 @@ const SECRET_PLACEHOLDER = /\{\{\s*secrets\.([A-Za-z0-9._-]+)\s*\}\}/g; * resolver is wired) is rejected loud, so an unsupported/unresolved placeholder is never passed as a literal. * * Exported for a focused unit test of the interpolation/fail-closed behavior (the resolved value is otherwise - * hidden inside the spawn closure of {@link resolveStdioServerConfigs}). + * hidden inside the spawn closure of {@link resolveServerConfigs}). */ export function buildChildEnv( serverId: string, @@ -272,8 +365,8 @@ export interface ConnectWorkflowMcpOptions { * server share one connection; the same id with conflicting connection settings is a fail-loud {@link CliError}), * starts them fail-loud, and returns the live {@link McpClient} plus a workflow whose inline agents each have * their `tools` grant unioned with ONLY their own declared servers' discovered tool ids (per-agent isolation via - * the manager's `toolIdsByServer`). Returns `undefined` when no inline agent declares a server. Stdio only — - * a network transport fails loud in {@link resolveStdioServerConfigs} (the Step-4 follow-up). + * the manager's `toolIdsByServer`). Returns `undefined` when no inline agent declares a server. Each transport + * (stdio + the network ones) is dispatched + SSRF-gated by {@link resolveServerConfigs}. */ export async function connectWorkflowMcp( def: WorkflowDefinition, @@ -305,7 +398,7 @@ export async function connectWorkflowMcp( } if (byId.size === 0) return undefined; - const configs = resolveStdioServerConfigs([...byId.values()], opts.cwd, opts.resolveSecret); + const configs = resolveServerConfigs([...byId.values()], opts.cwd, opts.resolveSecret); const client = await startMcpClientFailLoud(configs, opts.startMcpClient); // Augment each inline agent's grant with ONLY its own servers' discovered ids (a `$ref` entry passes through). diff --git a/docs/reference/contracts/config-spec.md b/docs/reference/contracts/config-spec.md index 4a39fc43..c892eb19 100644 --- a/docs/reference/contracts/config-spec.md +++ b/docs/reference/contracts/config-spec.md @@ -76,10 +76,12 @@ args = ["--root", "~/projects"] autostart = true # url = "https://host/mcp" # for transport = http (Streamable HTTP); a `websocket` server uses wss:// # env = { TOKEN = "..." } +# allow_local_endpoint = true # opt into a private/loopback url (network transports only, ADR-0053 §3) ``` A `transport = "http"` / `"websocket"` registration requires a `url` (`http(s)` for `http`, `ws(s)` for -`websocket`); the url is SSRF-guarded and must not embed credentials. An agent consumes a registration with +`websocket`); the url is SSRF-guarded (a private/loopback host is rejected unless `allow_local_endpoint` is set; +a remote host must be `https`/`wss`) and must not embed credentials. An agent consumes a registration with `- ref: filesystem` (see [../shared-core/mcp-integration.md](../shared-core/mcp-integration.md)). ## `project.toml` / `workspace.toml` (project) — keys diff --git a/docs/reference/shared-core/mcp-integration.md b/docs/reference/shared-core/mcp-integration.md index 15f25093..b17d0e89 100644 --- a/docs/reference/shared-core/mcp-integration.md +++ b/docs/reference/shared-core/mcp-integration.md @@ -48,7 +48,11 @@ mcp_servers: GITHUB_TOKEN: '{{secrets.github_token}}' # resolved from the isolated mcp-secret:* keychain (§6) - id: docs transport: http # http (Streamable HTTP) / websocket use `url` instead of `command` - url: 'https://docs.example/mcp' + url: 'https://docs.example/mcp' # remote ⇒ must be https/wss + - id: local-dev + transport: http + url: 'http://localhost:4000/mcp' + allow_local_endpoint: true # opt into a private/loopback url (relaxes the SSRF block + plaintext for it) # 2. BY-NAME `ref` — identity + connection come from a [[mcp_servers]] registration - ref: shared-fs # mutually exclusive with id/transport/command/url/env tools_allowlist: [read_file] # the only field allowed alongside `ref` diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 2fe1cc77..fd3e57fa 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -34,6 +34,9 @@ export { McpError, McpConnectError, McpHostUnavailableError } from './errors.js' export { shapeToolResult } from './result.js'; export { buildServerToolDefs, type ServerToolDefs, type SkippedTool } from './tool-mapping.js'; export { openStdioConnection, type StdioServerSpec } from './sdk-stdio.js'; +export { openHttpConnection, type HttpServerSpec } from './sdk-http.js'; +export { openSseConnection, type SseServerSpec } from './sdk-sse.js'; +export { openWebSocketConnection, type WebSocketServerSpec } from './sdk-websocket.js'; export { startMcpClient, type McpClient, diff --git a/packages/mcp/src/network-adapters.test.ts b/packages/mcp/src/network-adapters.test.ts new file mode 100644 index 00000000..9ae1eadd --- /dev/null +++ b/packages/mcp/src/network-adapters.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { McpError } from './errors.js'; +import { openHttpConnection } from './sdk-http.js'; +import { openSseConnection } from './sdk-sse.js'; +import { openWebSocketConnection } from './sdk-websocket.js'; + +/** + * The network transport adapters surface only Relavium shapes; a full connect needs a live server (the e2e + * fixture's job). Here we cover the two host-observable arms without a server: a malformed url is a typed + * connect failure, and the websocket adapter fails LOUD on a runtime with no global `WebSocket` (Node < 22). + */ + +describe('openHttpConnection / openSseConnection (malformed url)', () => { + it('a malformed url is a typed McpConnectError, not a raw throw', async () => { + await expect(openHttpConnection('h', { url: 'not a url' })).rejects.toThrow(McpError); + await expect(openSseConnection('s', { url: ':::bad' })).rejects.toThrow(McpError); + }); +}); + +describe('openWebSocketConnection (global WebSocket guard)', () => { + const saved = Reflect.get(globalThis, 'WebSocket') as unknown; + afterEach(() => { + // Restore whatever the runtime had (a function on Node 22+, undefined otherwise). + Reflect.set(globalThis, 'WebSocket', saved); + }); + + it('fails loud with a clear, typed error when there is no global WebSocket (Node < 22)', async () => { + Reflect.set(globalThis, 'WebSocket', undefined); + await expect(openWebSocketConnection('w', { url: 'wss://host/ws' })).rejects.toThrow( + /websocket transport requires a global WebSocket \(Node 22\+\)/, + ); + }); +}); diff --git a/packages/mcp/src/sdk-http.ts b/packages/mcp/src/sdk-http.ts new file mode 100644 index 00000000..6fcbfc51 --- /dev/null +++ b/packages/mcp/src/sdk-http.ts @@ -0,0 +1,40 @@ +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; + +import type { McpConnection } from './connection.js'; +import { McpConnectError } from './errors.js'; +import { connectSdkTransport } from './sdk-stdio.js'; + +/** + * The **Streamable HTTP** (`http`) transport adapter — one of the SDK-fenced files + * ([ADR-0052](../../../docs/decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §1, + * [ADR-0053](../../../docs/decisions/0053-mcp-network-transport-egress-security.md)). It opens the SDK's + * `StreamableHTTPClientTransport` and reuses the shared `connectSdkTransport` Client wrapper, surfacing only the + * Relavium {@link McpConnection} seam. The transport uses the runtime's global `fetch` (Node ≥ 18), so no extra + * dependency. **SSRF is the host's gate**: the CLI host validates the `url` against the shared range-block + * primitive (and the `allow_local_endpoint` opt-in) BEFORE calling this — the adapter itself only connects. + */ + +/** The explicit spec for a Streamable HTTP MCP server — a host-validated absolute `http(s)` url. */ +export interface HttpServerSpec { + readonly url: string; +} + +/** Connect a Streamable HTTP MCP server and run the initialize handshake; returns the live connection. */ +export async function openHttpConnection( + serverId: string, + spec: HttpServerSpec, +): Promise { + let endpoint: URL; + try { + endpoint = new URL(spec.url); + } catch (err) { + // A malformed url is a typed connect failure (secret-free; the host strips the opaque cause). + throw new McpConnectError(serverId, { cause: err }); + } + // The SDK's StreamableHTTP transport declares `get sessionId(): string | undefined`, which TS rejects against + // `Transport.sessionId?: string` under exactOptionalPropertyTypes — a vendor getter-vs-interface inconsistency, + // not a real incompatibility (the class `implements Transport`). Assert to the interface it implements. + const transport: Transport = new StreamableHTTPClientTransport(endpoint) as Transport; + return connectSdkTransport(serverId, transport); +} diff --git a/packages/mcp/src/sdk-sse.ts b/packages/mcp/src/sdk-sse.ts new file mode 100644 index 00000000..dc70cbe6 --- /dev/null +++ b/packages/mcp/src/sdk-sse.ts @@ -0,0 +1,33 @@ +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; + +import type { McpConnection } from './connection.js'; +import { McpConnectError } from './errors.js'; +import { connectSdkTransport } from './sdk-stdio.js'; + +/** + * The **legacy HTTP+SSE** (`sse`) transport adapter — one of the SDK-fenced files + * ([ADR-0052](../../../docs/decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §5). `sse` is a + * **deprecated alias** of `http`: the MCP spec replaced HTTP+SSE with Streamable HTTP, but a server declaring + * `sse` speaks the legacy wire protocol, so it connects via the SDK's `SSEClientTransport` (over global `fetch`). + * New servers should declare `http`. **SSRF is the host's gate** (the `http(s)`/`allow_local_endpoint` + * validation runs before this — `sse` uses an `http(s)` url like `http`). + */ + +/** The explicit spec for a legacy HTTP+SSE MCP server — a host-validated absolute `http(s)` url. */ +export interface SseServerSpec { + readonly url: string; +} + +/** Connect a legacy HTTP+SSE MCP server and run the initialize handshake; returns the live connection. */ +export async function openSseConnection( + serverId: string, + spec: SseServerSpec, +): Promise { + let endpoint: URL; + try { + endpoint = new URL(spec.url); + } catch (err) { + throw new McpConnectError(serverId, { cause: err }); + } + return connectSdkTransport(serverId, new SSEClientTransport(endpoint)); +} diff --git a/packages/mcp/src/sdk-stdio.ts b/packages/mcp/src/sdk-stdio.ts index ef217bc9..a03ee323 100644 --- a/packages/mcp/src/sdk-stdio.ts +++ b/packages/mcp/src/sdk-stdio.ts @@ -1,5 +1,6 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { JsonSchema } from '@relavium/core'; @@ -93,6 +94,20 @@ export async function openStdioConnection( ...(spec.args === undefined ? {} : { args: [...spec.args] }), ...(spec.cwd === undefined ? {} : { cwd: spec.cwd }), }); + return connectSdkTransport(serverId, transport); +} + +/** + * Create an MCP {@link Client}, run the initialize handshake over the given SDK transport, and wrap it as the + * SDK-type-free {@link McpConnection} seam. Shared by every transport adapter (stdio + the network adapters in + * `sdk-http.ts`/`sdk-websocket.ts`) so the Client lifecycle + tool shaping live in ONE place. A connect failure + * tears the client down and surfaces a typed, secret-free {@link McpConnectError} (its `cause` is opaque — the + * host strips it). The `transport` type is internal to the SDK fence; nothing outside `@relavium/mcp` sees it. + */ +export async function connectSdkTransport( + serverId: string, + transport: Transport, +): Promise { const client = new Client(CLIENT_INFO, { capabilities: {} }); try { await client.connect(transport); @@ -100,10 +115,10 @@ export async function openStdioConnection( await safeClose(client); throw new McpConnectError(serverId, { cause: err }); } - return new StdioConnection(client); + return new SdkConnection(client); } -class StdioConnection implements McpConnection { +class SdkConnection implements McpConnection { readonly #client: Client; constructor(client: Client) { diff --git a/packages/mcp/src/sdk-websocket.ts b/packages/mcp/src/sdk-websocket.ts new file mode 100644 index 00000000..e95a0ced --- /dev/null +++ b/packages/mcp/src/sdk-websocket.ts @@ -0,0 +1,43 @@ +import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'; + +import type { McpConnection } from './connection.js'; +import { McpConnectError, McpError } from './errors.js'; +import { connectSdkTransport } from './sdk-stdio.js'; + +/** + * The **WebSocket** (`websocket`) transport adapter — one of the SDK-fenced files + * ([ADR-0052](../../../docs/decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §1, + * [ADR-0053](../../../docs/decisions/0053-mcp-network-transport-egress-security.md)). It opens the SDK's + * `WebSocketClientTransport` and reuses the shared `connectSdkTransport` Client wrapper, surfacing only the + * Relavium {@link McpConnection} seam. + * + * **Runtime requirement: a global `WebSocket`.** The SDK's transport uses `new WebSocket(...)` (it has no `ws` + * dependency), so this requires a runtime with a global `WebSocket` — Node **22+** (the CLI's `engines` floor is + * 20.12). To avoid a new runtime dependency we fail loud with a clear, typed error on an older runtime rather + * than silently. **SSRF is the host's gate** (the `wss`/`allow_local_endpoint` validation runs before this). + */ + +/** The explicit spec for a WebSocket MCP server — a host-validated absolute `ws(s)` url. */ +export interface WebSocketServerSpec { + readonly url: string; +} + +/** Connect a WebSocket MCP server and run the initialize handshake; returns the live connection. */ +export async function openWebSocketConnection( + serverId: string, + spec: WebSocketServerSpec, +): Promise { + if (typeof globalThis.WebSocket !== 'function') { + throw new McpError( + `MCP server "${serverId}": the websocket transport requires a global WebSocket (Node 22+). ` + + `Upgrade Node, or use the 'http' (Streamable HTTP) transport instead.`, + ); + } + let endpoint: URL; + try { + endpoint = new URL(spec.url); + } catch (err) { + throw new McpConnectError(serverId, { cause: err }); + } + return connectSdkTransport(serverId, new WebSocketClientTransport(endpoint)); +} diff --git a/packages/shared/src/agent.test.ts b/packages/shared/src/agent.test.ts index f1c5a055..0d7c2094 100644 --- a/packages/shared/src/agent.test.ts +++ b/packages/shared/src/agent.test.ts @@ -235,6 +235,17 @@ describe('McpServerRefSchema', () => { ).toBe(false); // http → http(s), not ws(s) }); + it('accepts `allow_local_endpoint` on an inline network server (ADR-0053 §3)', () => { + expect( + McpServerRefSchema.safeParse({ + id: 'local', + transport: 'http', + url: 'http://localhost:4000/mcp', + allow_local_endpoint: true, + }).success, + ).toBe(true); + }); + describe('by-name `ref` form (ADR-0052 §5)', () => { it('accepts a bare { ref } and { ref, tools_allowlist } (the registration provides the connection)', () => { expect(McpServerRefSchema.safeParse({ ref: 'github' }).success).toBe(true); @@ -251,11 +262,18 @@ describe('McpServerRefSchema', () => { { args: ['-y', 'pkg'] }, { env: { TOKEN: 'x' } }, { url: 'https://h/mcp' }, + { allow_local_endpoint: true }, ]) { expect(McpServerRefSchema.safeParse({ ref: 'github', ...inline }).success).toBe(false); } }); + it('accepts `tools_allowlist` alongside `ref` (the only field allowed with it)', () => { + expect( + McpServerRefSchema.safeParse({ ref: 'github', tools_allowlist: ['read'] }).success, + ).toBe(true); + }); + it('rejects an inline entry missing id or transport (a ref is the only way to omit them)', () => { expect(McpServerRefSchema.safeParse({ transport: 'stdio', command: 'npx' }).success).toBe( false, diff --git a/packages/shared/src/agent.ts b/packages/shared/src/agent.ts index cda4fe94..1fb49d18 100644 --- a/packages/shared/src/agent.ts +++ b/packages/shared/src/agent.ts @@ -68,7 +68,15 @@ export const McpTransportSchema = z.enum(['stdio', 'http', 'websocket', 'sse']); const SAFE_MCP_URL = /^(https?|wss?):\/\//i; /** The inline connection fields a by-name `ref` must NOT carry (the registration provides them). */ -const INLINE_CONNECTION_FIELDS = ['id', 'transport', 'command', 'args', 'env', 'url'] as const; +const INLINE_CONNECTION_FIELDS = [ + 'id', + 'transport', + 'command', + 'args', + 'env', + 'url', + 'allow_local_endpoint', +] as const; /** * A reference to an MCP server an agent consumes (`McpServerRef`) — one of two mutually-exclusive forms @@ -91,6 +99,9 @@ export const McpServerRefSchema = z args: z.array(z.string()).optional(), env: z.record(z.string(), z.string()).optional(), url: z.string().url().optional(), + // Opt into a private/loopback network endpoint (ADR-0053 §3), scoped to the declared host:port — relaxes the + // SSRF range-block AND permits plaintext for THAT local endpoint only. Network transports only. + allow_local_endpoint: z.boolean().optional(), ref: nonEmptyString.optional(), tools_allowlist: z.array(nonEmptyString).optional(), }) diff --git a/packages/shared/src/config.test.ts b/packages/shared/src/config.test.ts index 85d4538a..416d2775 100644 --- a/packages/shared/src/config.test.ts +++ b/packages/shared/src/config.test.ts @@ -103,6 +103,21 @@ describe('config schemas', () => { ).toBe(false); // http needs url }); + it('accepts `allow_local_endpoint` on a network MCP registration (ADR-0053 §3)', () => { + expect( + GlobalConfigSchema.safeParse({ + mcp_servers: [ + { + name: 'local', + transport: 'http', + url: 'http://localhost:4000/mcp', + allow_local_endpoint: true, + }, + ], + }).success, + ).toBe(true); + }); + it('accepts a `websocket` MCP registration with a ws(s) url, rejecting a non-ws scheme (ADR-0052 §5)', () => { expect( GlobalConfigSchema.safeParse({ diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index 9a684eba..c153e266 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -33,6 +33,8 @@ export const McpServerRegistrationSchema = z autostart: z.boolean().optional(), url: z.string().url().optional(), env: z.record(z.string(), z.string()).optional(), + // Opt into a private/loopback network endpoint (ADR-0053 §3) — see `McpServerRefSchema`. Network transports only. + allow_local_endpoint: z.boolean().optional(), }) // .strict(): a typo in a committed MCP key (e.g. `autostrat`) fails loudly — strict config per ADR-0033 (which amends ADR-0023's config carve-out). .strict() From 6d95fb885e0a84eb2710df58853feba5c1d54362 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 27 Jun 2026 13:40:22 +0300 Subject: [PATCH 14/24] =?UTF-8?q?fix(mcp,cli,shared):=202.R=20Step=204c=20?= =?UTF-8?q?Opus=20review=20=E2=80=94=20close=20SSRF=20identity=20+=20scope?= =?UTF-8?q?=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the eight confirmed findings from the Opus multi-dimension review of the network-transport floor (Step 4c), all verified against current code: - serverFingerprint now includes `allow_local_endpoint` (`l`): a same-id pair where one agent opts into a local endpoint and the other does not no longer collapses first-wins, silently granting/denying BOTH the SSRF relaxation — it fails loud, forcing distinct ids or aligned flags (ADR-0053 §3). - agent.ts superRefine rejects `allow_local_endpoint` on a `stdio` transport (mirrors the stray-`url` guard) — a network-only field on stdio is a mis-declared server, caught at parse. - assertSafeNetworkEndpoint doc comment now states the opt-in's scope precisely: it relaxes exactly the AUTHORED `host:port` (the SDK dials precisely the validated url, so it cannot reach a sibling private port today), and is the host-validated FLOOR — DNS-rebind / redirect-to-private remain the deferred connect-by-validated-IP dialer's obligation. - sdk-http.ts drops the `as Transport` cast: narrowing to `Pick` (the only required members) both satisfies `Transport` structurally and sidesteps the vendor sessionId getter-vs-interface exactOptional mismatch, while compile-guarding a future dropped method. - deferred-tasks.md + mcp-integration.md now bind the future dialer to re-block any resolved/redirected `host:port` other than the authored one (SEC-EGRESS-3), not just the host. - Tests: SSRF bypass-encoding rejection matrix (decimal/hex/octal/short-form IPv4, IPv4-mapped IPv6, 0.0.0.0, trailing-dot, case), the divergent allow_local fingerprint dedup case, and the schema accept/reject pair for `allow_local_endpoint` on inline-network vs stdio. Refs: ADR-0053, ADR-0052, ADR-0029 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/engine/mcp-servers.test.ts | 30 +++++++++++++++++++ apps/cli/src/engine/mcp-servers.ts | 15 ++++++++-- docs/reference/shared-core/mcp-integration.md | 2 +- docs/roadmap/deferred-tasks.md | 2 +- packages/mcp/src/sdk-http.ts | 9 ++++-- packages/shared/src/agent.test.ts | 11 +++++++ packages/shared/src/agent.ts | 9 ++++++ 7 files changed, 71 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/engine/mcp-servers.test.ts b/apps/cli/src/engine/mcp-servers.test.ts index ba793957..bb837ec5 100644 --- a/apps/cli/src/engine/mcp-servers.test.ts +++ b/apps/cli/src/engine/mcp-servers.test.ts @@ -166,6 +166,21 @@ describe('SSRF floor (resolveServerConfigs network gate, 2.R Step 4c / ADR-0053) } }); + it('rejects canonical SSRF bypass-encodings of a loopback host (the shared primitive normalizes them)', () => { + // Lock the gate's host-extraction + the reuse of `isPrivateOrLocalHost` against an encoding-based bypass. + for (const url of [ + 'http://2130706433/mcp', // decimal 127.0.0.1 + 'http://0x7f000001/mcp', // hex 127.0.0.1 + 'http://0177.0.0.1/mcp', // octal-leading 127.0.0.1 + 'http://127.1/mcp', // inet_aton short form + 'http://LOCALHOST./mcp', // uppercase + trailing FQDN dot + 'http://[::ffff:127.0.0.1]/mcp', // IPv4-mapped IPv6 + 'http://0.0.0.0/mcp', // the "this host" wildcard + ]) { + expect(() => build({ url })).toThrow(/private\/loopback/); + } + }); + it('permits a private/loopback endpoint AND plaintext WITH allow_local_endpoint', () => { expect(build({ url: 'http://localhost:4000/mcp', allow_local_endpoint: true })).toHaveLength(1); expect( @@ -429,6 +444,21 @@ describe('connectWorkflowMcp (run path)', () => { ).rejects.toThrow(/conflicting settings/); }); + it('fails loud when two agents share a server id but DIFFER on allow_local_endpoint (no silent opt-in sharing)', async () => { + // The SSRF opt-in is part of the server identity (ADR-0053 §3): one agent's allow_local_endpoint must NOT be + // silently inherited by another sharing the id — the divergent pair fails loud. + const def = wf( + [ + ' - { id: scanner, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ id: local, transport: http, url: "http://localhost:4000/mcp", allow_local_endpoint: true }] }', + ' - { id: writer, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ id: local, transport: http, url: "http://localhost:4000/mcp" }] }', + '', + ].join('\n'), + ); + await expect( + connectWorkflowMcp(def, { cwd: '/w', startMcpClient: fakeStart(new Map()) }), + ).rejects.toThrow(/conflicting settings/); + }); + it('shares one connection when two agents share a server id with the SAME allowlist (order-insensitive)', async () => { // The allowlist is a set — declaration order must NOT spuriously conflict. `[read, write]` ≡ `[write, read]`. let startedWith: readonly McpServerConfig[] | undefined; diff --git a/apps/cli/src/engine/mcp-servers.ts b/apps/cli/src/engine/mcp-servers.ts index b5a250e3..41f2a9de 100644 --- a/apps/cli/src/engine/mcp-servers.ts +++ b/apps/cli/src/engine/mcp-servers.ts @@ -220,8 +220,14 @@ export function resolveServerConfigs( * link-local/metadata host is rejected UNLESS `allow_local_endpoint` is set (which, for that local endpoint, * also permits plaintext `http`/`ws` — a local-dev server is typically plaintext); a **remote** host must use * `https`/`wss` regardless of the flag. The no-embedded-credentials check is enforced here too (the flag never - * relaxes it). This is the host-validated FLOOR; the SDK transport opens its own socket, so the DNS-rebind - * connect-by-validated-IP upgrade is a tracked follow-up (deferred-tasks.md). + * relaxes it). + * + * **Scope (ADR-0053 §3 / SEC-EGRESS-3):** the opt-in relaxes exactly the **authored `host:port`** — the SDK + * transport dials precisely the validated `url`, so today the relaxation cannot reach a sibling private port + * (e.g. `:6379`/`:22`) on the same host. This is the host-validated **FLOOR**: it checks the AUTHORED host, so a + * hostname that DNS-resolves to a private IP, and a redirect-to-private, are NOT caught here — the + * connect-by-validated-IP dialer + per-hop re-validation (which **must** re-block any resolved/redirected + * `host:port` other than the authored one) is the tracked follow-up (deferred-tasks.md). */ function assertSafeNetworkEndpoint(serverId: string, url: string, allowLocal: boolean): void { let parsed: URL; @@ -444,7 +450,9 @@ function withWorkflowMcpGrant( * to whichever was declared first, granting BOTH agents the union (a privilege escalation past the narrower * agent's own declared `tools_allowlist`, violating ADR-0029 narrow-only). Including it makes that pair fail * loud, forcing the author to align the allowlists or give the distinct servers distinct ids. `undefined` - * (all-tools) is a distinct sentinel from `[]` (none). + * (all-tools) is a distinct sentinel from `[]` (none). **`allow_local_endpoint` is part of the identity too** + * (ADR-0053 §3): a same-id pair where one opts into a local endpoint and the other does not would otherwise + * collapse first-wins, silently granting (or denying) BOTH the SSRF relaxation — so it must fail loud. */ function serverFingerprint(ref: McpServerRef): string { const env = Object.entries(ref.env ?? {}).sort(([a], [b]) => a.localeCompare(b)); @@ -459,6 +467,7 @@ function serverFingerprint(ref: McpServerRef): string { u: ref.url ?? null, e: env, w: allowlist, + l: ref.allow_local_endpoint ?? false, }); } diff --git a/docs/reference/shared-core/mcp-integration.md b/docs/reference/shared-core/mcp-integration.md index b17d0e89..ace08800 100644 --- a/docs/reference/shared-core/mcp-integration.md +++ b/docs/reference/shared-core/mcp-integration.md @@ -109,7 +109,7 @@ This is also how the `mcp_call` workflow **trigger** works: a workflow with `tri ## Security -- **MCP server URLs are SSRF-guarded ([ADR-0029](../../decisions/0029-tool-policy-hardening.md)).** A declared MCP `url` is validated against the **same** vetted range-block as a provider base URL and the `http_request` tool — private/loopback/link-local/metadata ranges (`127.0.0.0/8`, `::1`, `10/8`, `172.16/12`, `192.168/16`, `169.254/16`) are rejected, and remote hosts must use `https`/`wss`, **unless the user explicitly opts into a local endpoint** (the per-server `allow_local_endpoint` flag, scoped to the declared `host:port`). A `http://localhost`/loopback `url` is exactly such a local endpoint and requires that explicit opt-in. The one SSRF primitive is reused, never re-implemented — see [security-review.md](../../standards/security-review.md). +- **MCP server URLs are SSRF-guarded ([ADR-0029](../../decisions/0029-tool-policy-hardening.md)).** A declared MCP `url` is validated against the **same** vetted range-block as a provider base URL and the `http_request` tool — private/loopback/link-local/metadata ranges (`127.0.0.0/8`, `::1`, `10/8`, `172.16/12`, `192.168/16`, `169.254/16`) are rejected, and remote hosts must use `https`/`wss`, **unless the user explicitly opts into a local endpoint** (the per-server `allow_local_endpoint` flag). A `http://localhost`/loopback `url` is exactly such a local endpoint and requires that explicit opt-in; the opt-in permits exactly the **authored `host:port`** (and plaintext for it). 2.R ships this as a **pre-connect floor** validating the authored host — a hostname that DNS-resolves to a private IP, or a redirect to one, is the residual window the connect-by-validated-IP dialer (per-hop re-validation against the authored `host:port`) closes; tracked in [../../roadmap/deferred-tasks.md](../../roadmap/deferred-tasks.md) ([ADR-0053](../../decisions/0053-mcp-network-transport-egress-security.md) §2). The one SSRF primitive is reused, never re-implemented — see [security-review.md](../../standards/security-review.md). - MCP server credentials are injected from the secret store via the server's `env` (e.g. `{{secrets.github_token}}`) and are **never** written into the workflow file or any event payload. See [../desktop/keychain-and-secrets.md](../desktop/keychain-and-secrets.md). - Outbound (workflow-as-MCP) exposure is opt-in per workflow (only those listed in the adapter config are published). - All inbound MCP tool calls are schema-validated before dispatch, and tool inputs in events are sanitized — see [built-in-tools.md](built-in-tools.md) and [../contracts/sse-event-schema.md](../contracts/sse-event-schema.md). diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 2504231d..625128d6 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -41,7 +41,7 @@ Severity is the review's verified rating. Check an item off in the PR that resol hook, it must apply these runtime checks. The current `assertHttpsBaseUrl` and `refineInFlightMediaPart` URL validation are construction-time / seam-ingestion-time policy; they catch malformed URLs but cannot catch DNS rebinding or a public hostname resolving to a private IP. **Scope split (resolving the earlier "Phase 2" framing):** the **media** url-carrier mechanism is **pulled into 1.AF** on a new bytes-shaped media-egress capability ([ADR-0043](../decisions/0043-media-egress-failover-rematerialization-ssrf.md)); the **general tool/MCP** `EgressCapability.fetch` enforcement still lands when the desktop/CLI surface implements that fetch hook. *(packages/core/src/tools/types.ts; security-review.md; media → 1.AF/ADR-0043; tool/MCP → surface fetch hook)* -- [ ] **MCP SDK network transport — upgrade to connect-by-validated-IP ([ADR-0053](../decisions/0053-mcp-network-transport-egress-security.md) §2).** 2.R ships **pre-connect host validation** as the floor for the `http` (Streamable HTTP) / `websocket` MCP transports — the `@modelcontextprotocol/sdk` opens its **own** socket, architecturally distinct from the `EgressCapability.fetch` hook above. When the SDK transport exposes an injectable `fetch`/dialer hook, upgrade to **connect-by-validated-IP**: resolve DNS → validate the IP against the shared range-block primitive → connect to that IP, re-validating on each redirect hop — closing the residual DNS-rebind window. Each MCP network mechanism gets a dedicated security-review pass when it lands. *(packages/mcp/src; ADR-0053 §2; ADR-0043 mechanism)* +- [ ] **MCP SDK network transport — upgrade to connect-by-validated-IP ([ADR-0053](../decisions/0053-mcp-network-transport-egress-security.md) §2).** 2.R ships **pre-connect host validation** as the floor for the `http` (Streamable HTTP) / `websocket` MCP transports — the `@modelcontextprotocol/sdk` opens its **own** socket, architecturally distinct from the `EgressCapability.fetch` hook above. When the SDK transport exposes an injectable `fetch`/dialer hook, upgrade to **connect-by-validated-IP**: resolve DNS → validate the IP against the shared range-block primitive → connect to that IP, re-validating on each redirect hop — closing the residual DNS-rebind window. **The dialer + redirect re-validation MUST enforce the authored `host:port`** (ADR-0053 §3 / SEC-EGRESS-3), not just the host: an `allow_local_endpoint` server is permitted exactly its declared `host:port`, so a resolved/redirected target on a *different* port of the same permitted-private host (`:6379`/`:5432`/`:22`/the Docker socket) must be re-blocked. (2.R's pre-connect floor is host:port-safe by construction — the SDK dials exactly the one authored url — so this constraint binds the dialer, not the floor.) Each MCP network mechanism gets a dedicated security-review pass when it lands. *(packages/mcp/src; ADR-0053 §2/§3; ADR-0043 mechanism)* - [ ] **MCP `stdio` spawn — import-trust/consent gate + `npx` dependency pinning ([ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §2).** Spawning a declared `stdio` MCP server runs arbitrary local code / an `npx`-installed package. 2.R treats a server declared in the user's **own** committed YAML as author trust; the **imported/shared untrusted workflow** case is out of baseline scope. When the import/share path matures, gate the first spawn of a server from an untrusted-provenance `.relavium.yaml` behind explicit consent, and pin the `npx` package version/integrity for the built-in auto-install servers. *(packages/mcp/src; apps/cli; ADR-0052 §2; ADR-0029 trust model)* - [ ] **MCP host boundary — strip `McpConnectError.cause` from `--json` / event output (2.R Step 3, [ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §2).** The typed MCP errors' `message` is secret-free + surfaceable, but `.cause` (the wrapped SDK/spawn error) MAY carry the spawned command/args (never env/secret data). When the CLI host wires `@relavium/mcp` (Step 3+), a connect/discovery failure must surface as a typed `CliError` whose message is safe, and the host MUST NOT serialize `cause` verbatim into a `--json` envelope or a `RunEvent` — strip it at that boundary. The contract is documented in `@relavium/mcp` `errors.ts`. *(apps/cli; packages/mcp/src/errors.ts; 2.R Step 3)* - [ ] **MCP follow-ups (non-security).** A durable cross-invocation **tool-list cache** (mcp-integration.md ~1h per-`(command,args)`, with a transport-covering key) — 2.R re-runs discovery per process ([ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §3); and a generalized **`SecretResolver`** seam beyond the 2.R `mcp-secret:*` keychain namespace ([ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §6); and reconciling the `types.ts` `ToolId` "register dynamically" comment to "host-side assembled" when 2.R touches `packages/core`; and **mid-call abort propagation** — the engine's `AbortSignalLike` is not forwarded to the in-flight MCP `tools/call` (the SDK transport wants a DOM `AbortSignal`), so a turn cancel tears the connection down but does not cancel an in-flight call (`@relavium/mcp` `manager.ts`). *(packages/mcp/src; packages/core; Phase-3)* diff --git a/packages/mcp/src/sdk-http.ts b/packages/mcp/src/sdk-http.ts index 6fcbfc51..cde1acb4 100644 --- a/packages/mcp/src/sdk-http.ts +++ b/packages/mcp/src/sdk-http.ts @@ -34,7 +34,12 @@ export async function openHttpConnection( } // The SDK's StreamableHTTP transport declares `get sessionId(): string | undefined`, which TS rejects against // `Transport.sessionId?: string` under exactOptionalPropertyTypes — a vendor getter-vs-interface inconsistency, - // not a real incompatibility (the class `implements Transport`). Assert to the interface it implements. - const transport: Transport = new StreamableHTTPClientTransport(endpoint) as Transport; + // not a real incompatibility (the class `implements Transport`). Rather than a whole-object `as Transport` + // (which would also silently mask a future MISSING-method drift), narrow to the load-bearing methods: those ARE + // the only required `Transport` members, so the value still satisfies `Transport` (its optionals may be absent) + // WITHOUT the getter comparison, and this assignment compile-guards against a dropped method on an SDK upgrade. + const transport: Pick = new StreamableHTTPClientTransport( + endpoint, + ); return connectSdkTransport(serverId, transport); } diff --git a/packages/shared/src/agent.test.ts b/packages/shared/src/agent.test.ts index 0d7c2094..68fbf806 100644 --- a/packages/shared/src/agent.test.ts +++ b/packages/shared/src/agent.test.ts @@ -246,6 +246,17 @@ describe('McpServerRefSchema', () => { ).toBe(true); }); + it('rejects `allow_local_endpoint` on a stdio transport (network-only field)', () => { + expect( + McpServerRefSchema.safeParse({ + id: 'fs', + transport: 'stdio', + command: 'npx', + allow_local_endpoint: true, + }).success, + ).toBe(false); + }); + describe('by-name `ref` form (ADR-0052 §5)', () => { it('accepts a bare { ref } and { ref, tools_allowlist } (the registration provides the connection)', () => { expect(McpServerRefSchema.safeParse({ ref: 'github' }).success).toBe(true); diff --git a/packages/shared/src/agent.ts b/packages/shared/src/agent.ts index 1fb49d18..143fa621 100644 --- a/packages/shared/src/agent.ts +++ b/packages/shared/src/agent.ts @@ -153,6 +153,15 @@ export const McpServerRefSchema = z path: ['url'], }); } + // `allow_local_endpoint` is a network-only SSRF opt-in — reject it on stdio (mirrors the `url` guard). + if (ref.allow_local_endpoint !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "allow_local_endpoint is not used by the 'stdio' transport (network transports only)", + path: ['allow_local_endpoint'], + }); + } } if (ref.transport !== 'stdio' && !ref.url) { ctx.addIssue({ From e5aa365445b15aab386a9c0c84ad16c67ea91638 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 27 Jun 2026 13:54:09 +0300 Subject: [PATCH 15/24] =?UTF-8?q?fix(shared,mcp,cli):=202.R=20Step=204c=20?= =?UTF-8?q?Sonnet=20review=20=E2=80=94=20close=20registration/inline=20sch?= =?UTF-8?q?ema=20asymmetry=20+=20adapter=20test=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the three distinct confirmed findings from the Sonnet multi-dimension review of the network-transport floor (Step 4c). All verified against current code; two further findings (resolveServerConfigs exhaustiveness; connectSdkTransport seam export) were adversarially rejected as already-handled and left untouched. - McpServerRegistrationSchema (config.ts) now rejects `allow_local_endpoint` on a `stdio` transport, mirroring the inline McpServerRefSchema (agent.ts). The registration schema previously accepted the network-only flag on stdio while an equivalent inline declaration refused it — a committed config could carry a dead opt-in that also skews `serverFingerprint` cross-agent dedup. Now fails loud at parse, with a paired rejection test in config.test.ts. - network-adapters.test.ts: cover openWebSocketConnection's malformed-url → McpConnectError arm (past the Node-22 guard, before any connect). The existing test set WebSocket=undefined so the guard fired first and that branch was unhit; the new case stubs a global WebSocket so the adapter's own `new URL()` parse trips. - mcp-servers.test.ts: add the accept counterpart to the SSRF fail-loud test — a `ref:` whose registration sets `allow_local_endpoint: true` on a private http url resolves and passes the floor — plus a resolveMcpServerRef unit test asserting the opt-in is preserved on the resolved ref (so the integration accept-arm is grounded). Refs: ADR-0053, ADR-0052, ADR-0033 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/engine/mcp-servers.test.ts | 41 +++++++++++++++++++++++ packages/mcp/src/network-adapters.test.ts | 8 +++++ packages/shared/src/config.test.ts | 12 +++++++ packages/shared/src/config.ts | 13 +++++++ 4 files changed, 74 insertions(+) diff --git a/apps/cli/src/engine/mcp-servers.test.ts b/apps/cli/src/engine/mcp-servers.test.ts index bb837ec5..fde54118 100644 --- a/apps/cli/src/engine/mcp-servers.test.ts +++ b/apps/cli/src/engine/mcp-servers.test.ts @@ -604,6 +604,28 @@ describe('connectWorkflowMcp (run path)', () => { }), ).rejects.toThrow(/private\/loopback/); }); + + it('PERMITS a `ref` to a private http url when the registration opts in via allow_local_endpoint (the accept arm)', async () => { + // The accept counterpart to the fail-loud floor test: the opt-in flows registration → resolveMcpServerRef + // → the SSRF gate, which permits exactly the authored private host:port without throwing (ADR-0053 §3). + const def = wf( + ` - { id: scanner, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ ref: local }] }\n`, + ); + const runtime = await connectWorkflowMcp(def, { + cwd: '/w', + registrations: [ + { + name: 'local', + transport: 'http', + url: 'http://127.0.0.1:4000/mcp', + allow_local_endpoint: true, + }, + ], + startMcpClient: fakeStart(new Map([['local', ['mcp_local_x']]])), + }); + expect(runtime).toBeDefined(); // the opt-in lets the private ref pass the floor (no fail-loud) + expect(agentOf(runtime!.workflow, 'scanner').tools).toEqual(['mcp_local_x']); + }); }); describe('resolveMcpServerRef (by-name resolution, 2.R Step 4b)', () => { @@ -628,6 +650,25 @@ describe('resolveMcpServerRef (by-name resolution, 2.R Step 4b)', () => { }); }); + it('preserves a registration `allow_local_endpoint` opt-in on the resolved network ref', () => { + // The SSRF opt-in must survive resolution so the host-side floor honors it (ADR-0053 §3) — assert the + // flag is carried through, not dropped, so the connectWorkflowMcp accept-arm above is grounded in unit code. + const netRegs: McpServerRegistration[] = [ + { + name: 'local', + transport: 'http', + url: 'http://127.0.0.1:4000/mcp', + allow_local_endpoint: true, + }, + ]; + expect(resolveMcpServerRef({ ref: 'local' }, netRegs)).toEqual({ + id: 'local', + transport: 'http', + url: 'http://127.0.0.1:4000/mcp', + allow_local_endpoint: true, + }); + }); + it('fails loud (typed exit-2 CliError) when the ref names no registration', () => { try { resolveMcpServerRef({ ref: 'nope' }, regs); diff --git a/packages/mcp/src/network-adapters.test.ts b/packages/mcp/src/network-adapters.test.ts index 9ae1eadd..38da4578 100644 --- a/packages/mcp/src/network-adapters.test.ts +++ b/packages/mcp/src/network-adapters.test.ts @@ -31,4 +31,12 @@ describe('openWebSocketConnection (global WebSocket guard)', () => { /websocket transport requires a global WebSocket \(Node 22\+\)/, ); }); + + it('a malformed url is a typed McpConnectError (past the guard, before any connect)', async () => { + // Stub a global WebSocket so the Node-22 guard passes; the malformed url then trips the adapter's own + // `new URL()` parse — a typed McpConnectError — BEFORE `new WebSocketClientTransport` is ever reached, + // so no socket is opened. This covers the websocket adapter's error-surface arm the http/sse test covers. + Reflect.set(globalThis, 'WebSocket', function StubWebSocket() {}); + await expect(openWebSocketConnection('w', { url: ':::bad' })).rejects.toThrow(McpError); + }); }); diff --git a/packages/shared/src/config.test.ts b/packages/shared/src/config.test.ts index 416d2775..4b76144c 100644 --- a/packages/shared/src/config.test.ts +++ b/packages/shared/src/config.test.ts @@ -118,6 +118,18 @@ describe('config schemas', () => { ).toBe(true); }); + it('rejects `allow_local_endpoint` on a stdio MCP registration (network-only — matches the inline ref schema)', () => { + // The inline `McpServerRefSchema` (agent.ts) rejects the network-only flag on stdio; the registration + // schema must agree, so a committed config can't carry a dead opt-in that an equivalent inline entry refuses. + expect( + GlobalConfigSchema.safeParse({ + mcp_servers: [ + { name: 'fs', transport: 'stdio', command: 'npx', allow_local_endpoint: true }, + ], + }).success, + ).toBe(false); + }); + it('accepts a `websocket` MCP registration with a ws(s) url, rejecting a non-ws scheme (ADR-0052 §5)', () => { expect( GlobalConfigSchema.safeParse({ diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index c153e266..d99f3de6 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -46,6 +46,19 @@ export const McpServerRegistrationSchema = z path: ['command'], }); } + // `allow_local_endpoint` is a network-only SSRF opt-in — reject it on stdio so a registration's + // contract matches the inline `McpServerRefSchema` (agent.ts), which rejects the same field on stdio. + // Without this, a committed `[[mcp_servers]]` stdio entry could carry a dead flag that silently passes + // here yet fails an equivalent inline declaration — and, because it is part of `serverFingerprint`, it + // could skew cross-agent dedup. Network transports only (ADR-0053 §3). + if (server.transport === 'stdio' && server.allow_local_endpoint !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "allow_local_endpoint is not used by the 'stdio' transport (network transports only)", + path: ['allow_local_endpoint'], + }); + } if (server.transport !== 'stdio' && !server.url) { ctx.addIssue({ code: z.ZodIssueCode.custom, From 9c5d2f5c7286c39049d8a3fdd7f2ce67a50c12b1 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 27 Jun 2026 14:09:41 +0300 Subject: [PATCH 16/24] =?UTF-8?q?test(cli,mcp):=202.R=20Step=205=20?= =?UTF-8?q?=E2=80=94=20real-spawn=20stdio=20MCP=20e2e=20(chat=20+=20run=20?= =?UTF-8?q?hosts)=20against=20a=20genuine=20SDK=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the inbound-MCP real-spawn end-to-end test: the CLI host actually spawns a stdio MCP child (`node `), runs the initialize + tools/list handshake, namespaces the discovered tools, routes real tools/calls, and tears the child down — covering the seam (@relavium/mcp adapters + node:child_process) end-to-end that the unit tests stub with a fake startMcpClient. - packages/mcp/test-fixtures/echo-mcp-server.mjs — a deterministic, offline stdio MCP server (McpServer + StdioServerTransport) exposing `echo` + `add`. It lives in packages/mcp because that is the only workspace where the SDK resolves (the seam owner); it is a standalone test process, never part of the package's import graph, so the four-adapter SDK/node:* confinement is not weakened. - apps/cli/src/harness/mcp-stdio.e2e.test.ts — two surfaces, two host entry points: connectAgentMcp (chat) returns the live client; connectWorkflowMcp (run) returns the client PLUS the workflow whose inline agent grant is unioned with its server's discovered tool ids. Both assert discovery (mcp_echo_echo / mcp_echo_add), a real round-trip (echo verbatim, add → 42/"wired"), and teardown. Deterministic, offline, no LLM/provider key — runs on every PR like the rest of the harness. Refs: ADR-0052 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/harness/mcp-stdio.e2e.test.ts | 128 ++++++++++++++++++ .../mcp/test-fixtures/echo-mcp-server.mjs | 35 +++++ 2 files changed, 163 insertions(+) create mode 100644 apps/cli/src/harness/mcp-stdio.e2e.test.ts create mode 100644 packages/mcp/test-fixtures/echo-mcp-server.mjs diff --git a/apps/cli/src/harness/mcp-stdio.e2e.test.ts b/apps/cli/src/harness/mcp-stdio.e2e.test.ts new file mode 100644 index 00000000..ca7e6d75 --- /dev/null +++ b/apps/cli/src/harness/mcp-stdio.e2e.test.ts @@ -0,0 +1,128 @@ +import { fileURLToPath } from 'node:url'; + +import { parseWorkflow } from '@relavium/core'; +import type { McpToolResult } from '@relavium/mcp'; +import type { Agent, McpServerRef } from '@relavium/shared'; +import { describe, expect, it } from 'vitest'; + +import { connectAgentMcp, connectWorkflowMcp } from '../engine/mcp-servers.js'; + +/** + * The 2.R Step 5 **real-spawn** MCP e2e — the inbound-MCP host path exercised against a genuine + * `@modelcontextprotocol/sdk` server (NOT a fake `startMcpClient`). The CLI host actually spawns a stdio MCP + * child (`node `), runs the `initialize` + `tools/list` handshake, namespaces the discovered tools, + * routes a real `tools/call`, and tears the child down — covering the seam (`@relavium/mcp` adapters + + * `node:child_process`) end-to-end that the unit tests stub. Deterministic + offline: the fixture is a local + * Node child, no LLM, no provider key, no network — so it runs on every PR like the rest of the harness. + * + * Two surfaces, two host entry points: + * - `connectAgentMcp` — the **chat** host helper: returns the live client to wire onto the session. + * - `connectWorkflowMcp` — the **run** host helper: returns the client PLUS the workflow whose inline agent's + * `tools` grant is unioned with its server's discovered tool ids (the run-path's distinctive augmentation). + * + * The fixture lives in `packages/mcp/test-fixtures` (the only workspace where the SDK resolves — the seam + * owner); we spawn it by absolute path with the SAME node binary that runs the test (`process.execPath`). + */ +const FIXTURE = fileURLToPath( + new URL('../../../../packages/mcp/test-fixtures/echo-mcp-server.mjs', import.meta.url), +); + +/** The echo fixture as an inline stdio `McpServerRef`, spawned with this process's node binary. */ +const echoServer = (id = 'echo'): McpServerRef => ({ + id, + transport: 'stdio', + command: process.execPath, + args: [FIXTURE], +}); + +/** Narrow the capability's `unknown` result to the Relavium tool-result shape for assertion. */ +const asResult = (value: unknown): McpToolResult => value as McpToolResult; + +describe('inbound MCP — real stdio spawn (2.R Step 5)', () => { + it('chat host: spawns the fixture, discovers + namespaces its tools, round-trips a real tools/call', async () => { + const client = await connectAgentMcp([echoServer()], { cwd: process.cwd() }); + expect(client).toBeDefined(); + try { + // Discovery: both fixture tools are namespaced `mcp_{server}_{tool}`, grouped under the server id. + expect([...(client!.toolIdsByServer.get('echo') ?? [])].sort()).toEqual([ + 'mcp_echo_add', + 'mcp_echo_echo', + ]); + expect(client!.toolDefs.map((d) => d.id).sort()).toEqual(['mcp_echo_add', 'mcp_echo_echo']); + expect(client!.skipped).toEqual([]); + + // A real tools/call over the spawned child round-trips (echo returns its text verbatim). + const echoed = asResult( + await client!.capability.call({ server: 'echo', tool: 'echo', args: { text: 'pong' } }), + ); + expect(echoed.isError).toBe(false); + expect(echoed.content).toEqual([{ type: 'text', text: 'pong' }]); + + // The second tool proves multi-tool routing (args validated by the compiled inputSchema). + const sum = asResult( + await client!.capability.call({ server: 'echo', tool: 'add', args: { a: 2, b: 40 } }), + ); + expect(sum.content).toEqual([{ type: 'text', text: '42' }]); + } finally { + await client!.close(); // tears the stdio child down (idempotent) + } + }); + + it('run host: augments the declaring agent grant with the discovered ids and routes a real call', async () => { + // The absolute spawn command + fixture path are machine-specific, so they're injected (double-quoted — + // valid YAML flow scalars) rather than hard-coded; the parsed workflow stays immutable. + const def = parseWorkflow( + [ + "schema_version: '1.0'", + 'workflow:', + ' id: wf', + ' agents:', + ' - id: scanner', + ' model: claude-sonnet-4-6', + ' provider: anthropic', + ' system_prompt: go', + ' tools: [read_file]', + ' mcp_servers:', + ` - { id: echo, transport: stdio, command: "${process.execPath}", args: ["${FIXTURE}"] }`, + ' nodes:', + ' - { id: s, type: input }', + ' - { id: a, type: agent, agent_ref: scanner, prompt_template: go }', + ' - { id: o, type: output }', + ' edges:', + ' - { from: s, to: a }', + ' - { from: a, to: o }', + '', + ].join('\n'), + ); + + const runtime = await connectWorkflowMcp(def, { cwd: process.cwd() }); + expect(runtime).toBeDefined(); + try { + expect([...(runtime!.client.toolIdsByServer.get('echo') ?? [])].sort()).toEqual([ + 'mcp_echo_add', + 'mcp_echo_echo', + ]); + + // The run path unions the agent's declared grant with ITS server's discovered tool ids. + const augmented = (runtime!.workflow.workflow.agents ?? []).find( + (e): e is Agent => 'id' in e && e.id === 'scanner', + )!; + expect([...(augmented.tools ?? [])].sort()).toEqual([ + 'mcp_echo_add', + 'mcp_echo_echo', + 'read_file', + ]); + + const echoed = asResult( + await runtime!.client.capability.call({ + server: 'echo', + tool: 'echo', + args: { text: 'wired' }, + }), + ); + expect(echoed.content).toEqual([{ type: 'text', text: 'wired' }]); + } finally { + await runtime!.client.close(); + } + }); +}); diff --git a/packages/mcp/test-fixtures/echo-mcp-server.mjs b/packages/mcp/test-fixtures/echo-mcp-server.mjs new file mode 100644 index 00000000..75ccd3cf --- /dev/null +++ b/packages/mcp/test-fixtures/echo-mcp-server.mjs @@ -0,0 +1,35 @@ +// A deterministic, dependency-light stdio MCP **server** used by the real-spawn e2e (2.R Step 5). +// +// It is NOT part of the shipped package — it is a test fixture the CLI host actually spawns over stdio +// (`node `) so the inbound-MCP client path (spawn → initialize → tools/list → tools/call → +// teardown) is exercised against a genuine `@modelcontextprotocol/sdk` server, not a fake. It lives under +// `packages/mcp` because that is the only workspace where the SDK resolves (the seam owner); the SDK + +// `node:*` confinement that the four `sdk-*.ts` adapters enforce does not apply to a standalone test process. +// +// Two tools, both deterministic and offline: +// - `echo` → returns its `text` argument verbatim (the round-trip proof); +// - `add` → returns `a + b` as text (a second tool, so multi-tool discovery is asserted). +// +// Fail-fast: any startup error exits non-zero so the spawning host's fail-loud connect surfaces it. +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; + +const server = new McpServer({ name: 'echo-fixture', version: '1.0.0' }); + +server.registerTool( + 'echo', + { description: 'Echo the provided text back unchanged.', inputSchema: { text: z.string() } }, + ({ text }) => ({ content: [{ type: 'text', text }] }), +); + +server.registerTool( + 'add', + { + description: 'Add two integers and return the sum as text.', + inputSchema: { a: z.number(), b: z.number() }, + }, + ({ a, b }) => ({ content: [{ type: 'text', text: String(a + b) }] }), +); + +await server.connect(new StdioServerTransport()); From 1ad473ba07d01872d8d4ab79f8559a9492f9530c Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 27 Jun 2026 14:13:18 +0300 Subject: [PATCH 17/24] =?UTF-8?q?docs(mcp):=202.R=20Step=205=20=E2=80=94?= =?UTF-8?q?=20reconcile=20the=20inbound-MCP=20reference=20with=20the=20shi?= =?UTF-8?q?pped=20host-side=20reality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make mcp-integration.md honest against what 2.R actually ships: - Inbound lifecycle is attributed to the HOST, not "the engine" — the host owns the SDK + child processes and assembles the namespaced ToolDefs + McpCapability it hands to the platform-pure engine (ADR-0052 §1 host-side assembly); the engine routes calls through host.mcp.call and never touches the SDK. Connect is fail-loud. - Tool-discovery table corrected: a `tools_allowlist` is a post-`tools/list` NARROWING (not load-time pre-resolution that skips discovery); namespacing disambiguates collisions and a residual post-namespace collision fails closed; schema validation compiles the server JSON Schema at discovery (unsupported → tool dropped) and validates each call's args before dispatch. - Honest "not yet shipped (2.R)" note: tool-list caching is a tracked follow-up (2.R re-runs tools/list per connect), there is no curated built-in-server catalog (servers are declared explicitly; npx fetches on first spawn, not Relavium), and the registration `autostart` field is schema-accepted but not acted on (on-demand connect) — reserved for a future always-on pool. Refs: ADR-0052 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/reference/shared-core/mcp-integration.md | 37 +++++++------------ 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/docs/reference/shared-core/mcp-integration.md b/docs/reference/shared-core/mcp-integration.md index ace08800..07157335 100644 --- a/docs/reference/shared-core/mcp-integration.md +++ b/docs/reference/shared-core/mcp-integration.md @@ -23,13 +23,13 @@ flowchart LR ## Agents consuming MCP tools (inbound) -An agent declares the MCP servers it uses in its `mcp_servers` list (see [../contracts/agent-yaml-spec.md](../contracts/agent-yaml-spec.md)). At agent startup the engine: +An agent declares the MCP servers it uses in its `mcp_servers` list (see [../contracts/agent-yaml-spec.md](../contracts/agent-yaml-spec.md)). Connection is **host-side assembly** ([ADR-0052](../../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §1): the **host** (the CLI/VS Code Node process, or the desktop Rust backend) owns the MCP client and the SDK + child processes — the engine (`packages/core`) stays platform-pure and never imports the SDK or `node:child_process`. At session/run startup the host: -1. **Spawns** (stdio transport) or **connects to** (Streamable HTTP / WebSocket) each declared MCP server. -2. Calls `tools/list` on each server and registers the discovered tools into the agent's tool namespace as `mcp_{server_id}_{tool_name}`. -3. Routes any tool call the agent makes to the correct MCP server using that registered mapping. -4. Streams results back as `agent:tool_result` events (see [../contracts/sse-event-schema.md](../contracts/sse-event-schema.md)). -5. Keeps the MCP server processes alive for the run duration, then tears them down. +1. **Spawns** (stdio transport) or **connects to** (Streamable HTTP / WebSocket) each declared MCP server, **fail-loud** — a failed spawn or `tools/list` fails the whole start, never a silent capability loss. +2. Calls `tools/list` on each server and shapes the discovered tools into namespaced Relavium `ToolDef`s — `mcp_{server_id}_{tool_name}` — assembling them plus an `McpCapability` it hands to the engine's tool registry. +3. The engine routes any tool call the agent makes through that `McpCapability` (`host.mcp.call`) to the correct server — it never touches the SDK. +4. Results stream back as `agent:tool_result` events (see [../contracts/sse-event-schema.md](../contracts/sse-event-schema.md)). +5. The host keeps the MCP server connections alive for the session/run duration, then tears them down. The `mcp_call` built-in tool is the lower-level path for invoking a registered server's tool by name directly from a `tool` node (see [built-in-tools.md](built-in-tools.md)). @@ -60,29 +60,18 @@ mcp_servers: The **transport vocabulary** is reconciled to the current MCP spec: `http` is the **Streamable HTTP** transport (the SDK's `StreamableHTTPClientTransport`); `sse` is a **deprecated alias** of `http` (the legacy HTTP+SSE transport, accepted for older servers); `websocket` uses a `wss://` url. A network `url` is SSRF-guarded (below). -Server **registrations** also live globally in `~/.relavium/config.toml` under repeatable `[[mcp_servers]]` entries (with `autostart`), so a server can be registered once and referenced **by name** (`ref:`) from many agents. The merge of global and project-scoped servers follows the normal config resolution order — see [../contracts/config-spec.md](../contracts/config-spec.md). +Server **registrations** also live globally in `~/.relavium/config.toml` under repeatable `[[mcp_servers]]` entries, so a server can be registered once and referenced **by name** (`ref:`) from many agents. A referenced server connects **on demand** when an agent that uses it starts; the registration's `autostart` field is accepted by the schema but reserved for a future always-on pool (not acted on in 2.R). The merge of global and project-scoped servers follows the normal config resolution order — see [../contracts/config-spec.md](../contracts/config-spec.md). ### Tool discovery | Mode | When | Behavior | | --- | --- | --- | -| **Static** | a `tools_allowlist` is declared on the server entry | tools are pre-resolved at workflow load time — fast, deterministic. | -| **Dynamic** | no allowlist | the engine calls `tools/list` at agent init and registers every available tool. | -| **Caching** | always | tool lists are cached per `(server_command, args)` hash for ~1 hour to avoid re-spawning. | -| **Conflict resolution** | two servers expose the same tool name | the engine prefixes with the server id (`mcp_github_create_issue` vs `mcp_jira_create_issue`) — the same `mcp_{server}_{tool}` namespacing used everywhere, which is what disambiguates the collision. | -| **Schema validation** | every call | the engine validates each MCP tool call against the server-reported JSON Schema before sending — malformed calls are rejected early. | - -### Built-in MCP servers - -These are available out of the box and auto-installed on first use (via `npx`): - -| Server | Capability | -| --- | --- | -| `@modelcontextprotocol/server-filesystem` | read/write local files | -| `@modelcontextprotocol/server-brave-search` | web search | -| `@modelcontextprotocol/server-puppeteer` | browser automation | -| `@modelcontextprotocol/server-github` | GitHub API | -| `@modelcontextprotocol/server-postgres` | database access | +| **Dynamic** | no `tools_allowlist` | the host calls `tools/list` at connect and admits every discovered tool. | +| **Allowlisted** | a `tools_allowlist` is declared on the server entry | the host still calls `tools/list`, then **narrows** the admitted set to the named tools (the rest are skipped + surfaced) — deterministic in *which* tools an agent may call. | +| **Conflict resolution** | two servers expose the same tool name | the host namespaces every tool as `mcp_{server}_{tool}` (`mcp_github_create_issue` vs `mcp_jira_create_issue`), which disambiguates the collision; a residual collision *after* namespacing **fails closed** (the colliding tool is skipped, never silently shadowing another). | +| **Schema validation** | every call | the host compiles the server-reported JSON Schema into a validator at discovery — an `inputSchema` outside the supported subset **drops the tool** (fail-closed, never admitted unvalidated) — and each call's args are validated against it before dispatch. | + +> **Not yet shipped (2.R):** tool-list **caching** (re-spawn avoidance via a `(command, args)` hash) is a tracked follow-up — 2.R re-runs `tools/list` on each connect. There is no curated catalog of "built-in" servers either; any server is declared explicitly (a `command: npx …` entry is fetched on first spawn by `npx` itself, not by Relavium). Common choices: `@modelcontextprotocol/server-filesystem`, `…-github`, `…-postgres`, `…-brave-search`, `…-puppeteer`. On the desktop, stdio MCP servers are managed as child processes by the Rust backend, which owns their lifecycle (start on demand, keep alive for the session, restart on crash). In the CLI and VS Code surfaces the same servers are spawned by the Node.js host. The pooling/lifecycle design narrative is in [../../architecture/shared-core-engine.md](../../architecture/shared-core-engine.md). From 0f739713fa73bb7f2a2204e54ff13413bd4cd72a Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 27 Jun 2026 20:48:35 +0300 Subject: [PATCH 18/24] =?UTF-8?q?fix(shared,cli,mcp,docs):=202.R=20final?= =?UTF-8?q?=20holistic=20Opus=20review=20=E2=80=94=20close=20env-on-networ?= =?UTF-8?q?k=20drop=20+=20teardown/test/doc=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the nine confirmed findings from the final holistic Opus review of the complete inbound-MCP lane (steps 3a–5), all verified against current code. These are the cross-step / integration catches the per-step reviews could not see. - [MEDIUM] env-on-network silent drop: `env` (and its `{{secrets.*}}`) was schema- accepted on ANY transport but `resolveServerConfigs` reads it only in the stdio branch — a network server's `env` was silently discarded, so a `{{secrets.*}}` auth placeholder was thrown away rather than resolved-or-rejected (a Step-4a × Step-4c seam). Both `McpServerRefSchema` and `McpServerRegistrationSchema` now reject `env` on a network transport (fail-closed, mirroring the stdio url/ allow_local guards); docs scope `env` to stdio; network header-auth is tracked. - [LOW] connectWorkflowMcp is now self-cleaning: if the post-connect grant augmentation throws, the live client is torn down before rethrowing — uniform all-or-nothing with the chat session builders. - [LOW] the cross-agent conflict error now explains a sanitized-registration-name collision (two distinct names collapsing to one namespace id). - [MEDIUM] chat / chat-resume MCP teardown + skip-surfacing now have command-level coverage: skip note → stderr (never stdout), closeMcp awaited once at REPL teardown, the pre-loop-fault orphan guard, and the resumed-session teardown. - docs honesty: tick the resolved cause-strip deferred item; security-review.md MCP-URL entry gains the allow_local_endpoint opt-in + pre-connect-floor caveat; config-spec.md notes autostart is not acted on and env is stdio-only. - the real-spawn e2e now closes the LAST hop of the secret-custody chain: a `{{secrets.t}}` env placeholder resolves to a sentinel that the spawned child's new `whoami` tool round-trips back — proving the resolved secret reaches the real process env (and only there). Refs: ADR-0052, ADR-0053, ADR-0029, ADR-0033 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/commands/chat.test.ts | 126 +++++++++++++++++- apps/cli/src/engine/mcp-servers.ts | 29 ++-- apps/cli/src/harness/mcp-stdio.e2e.test.ts | 37 ++++- docs/reference/contracts/config-spec.md | 4 +- docs/reference/shared-core/mcp-integration.md | 4 +- docs/roadmap/deferred-tasks.md | 3 +- docs/standards/security-review.md | 16 ++- .../mcp/test-fixtures/echo-mcp-server.mjs | 16 ++- packages/shared/src/agent.test.ts | 21 +++ packages/shared/src/agent.ts | 26 +++- packages/shared/src/config.test.ts | 18 +++ packages/shared/src/config.ts | 26 +++- 12 files changed, 289 insertions(+), 37 deletions(-) diff --git a/apps/cli/src/commands/chat.test.ts b/apps/cli/src/commands/chat.test.ts index 148eb781..a7c9efa8 100644 --- a/apps/cli/src/commands/chat.test.ts +++ b/apps/cli/src/commands/chat.test.ts @@ -6,9 +6,10 @@ import { PassThrough, Readable } from 'node:stream'; import type { SessionStreamHandleEvent } from '@relavium/core'; import type { StreamChunk } from '@relavium/llm'; import { createClient, createSessionStore, runMigrations, type DbClient } from '@relavium/db'; +import { startMcpClient as realStartMcpClient, type McpConnection } from '@relavium/mcp'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { buildChatSession } from '../chat/session-host.js'; +import { buildChatSession, buildResumedChatSession } from '../chat/session-host.js'; import { scriptedResolver, textTurn, @@ -59,6 +60,36 @@ function linesDriver(lines: readonly string[]): ChatDriver { }; } +/** An --agent file declaring one stdio MCP server (the injected startMcpClient never spawns `command: x`). */ +const MCP_AGENT_YAML = [ + 'id: mcpcoder', + 'provider: anthropic', + 'model: claude-sonnet-4-6', + 'system_prompt: You are a coder.', + 'mcp_servers:', + ' - id: fs', + ' transport: stdio', + ' command: x', +].join('\n'); + +/** A fake MCP connection whose `close` counts teardowns; `read` is allowed, `danger` is dropped (skip note). */ +function mcpConn(): { conn: McpConnection; closed: () => number } { + let n = 0; + const conn: McpConnection = { + listTools: () => + Promise.resolve([ + { name: 'read', inputSchema: { type: 'object' } }, + { name: 'danger', inputSchema: { type: 'object' } }, + ]), + callTool: () => Promise.resolve({ content: [], isError: false }), + close: () => { + n += 1; + return Promise.resolve(); + }, + }; + return { conn, closed: () => n }; +} + describe('chatCommand', () => { let cwd: string; let home: string; @@ -362,6 +393,63 @@ describe('chatCommand', () => { }; await expect(chatCommand({ agent: undefined }, bad)).rejects.toThrow(/cannot infer a provider/); }); + + /** Write an --agent file declaring one stdio MCP server (the injected startMcpClient never spawns it). */ + function writeMcpAgent(): string { + const p = join(cwd, 'mcp.agent.yaml'); + writeFileSync(p, MCP_AGENT_YAML); + return p; + } + + it('an MCP-declaring chat agent: surfaces dropped tools to stderr and tears the connection down at REPL teardown (2.R)', async () => { + // chat.ts's OWN command-level MCP wiring: surfaceMcpSkipped (→ stderr, never the stdout chrome) + the + // closeMcp teardown in runReplLoop's finally. Drives the REAL buildChatSession over a fake connection. + const agentPath = writeMcpAgent(); + const { conn, closed } = mcpConn(); + const { d, out, err } = deps(['/exit'], [textTurn('done')]); + const buildSession: typeof buildChatSession = (o) => + buildChatSession({ + ...o, + startMcpClient: () => + realStartMcpClient([ + { id: 'fs', toolsAllowlist: ['read'], open: () => Promise.resolve(conn) }, + ]), + }); + const code = await chatCommand({ agent: agentPath }, { ...d, buildSession }); + expect(code).toBe(EXIT_CODES.chatEnded); + expect(err()).toContain("MCP tool 'danger'"); // the allowlist-dropped tool note went to stderr + expect(out()).not.toContain('danger'); // …never to stdout + expect(closed()).toBe(1); // torn down exactly once at REPL teardown (runReplLoop finally) + }); + + it('tears the MCP connection down when opening the session store throws AFTER a successful build (orphan guard, 2.R)', async () => { + // The build→loop window: the session already OWNS the live connection, but openSessionStore throws before the + // REPL loop's steady-state finally — chat.ts's catch must close the connection (no orphaned child) and rethrow. + const agentPath = writeMcpAgent(); + const { conn, closed } = mcpConn(); + const { d } = deps(['/exit'], [textTurn('x')]); + const buildSession: typeof buildChatSession = (o) => + buildChatSession({ + ...o, + startMcpClient: () => + realStartMcpClient([ + { id: 'fs', toolsAllowlist: ['read'], open: () => Promise.resolve(conn) }, + ]), + }); + await expect( + chatCommand( + { agent: agentPath }, + { + ...d, + buildSession, + openSessionStore: () => { + throw new Error('db open boom'); + }, + }, + ), + ).rejects.toThrow('db open boom'); + expect(closed()).toBe(1); // the pre-loop catch tore the live connection down before rethrowing + }); }); describe('chatResumeCommand (2.N)', () => { @@ -638,6 +726,42 @@ describe('chatResumeCommand (2.N)', () => { /no stored agent snapshot/, ); }); + + it('re-discovers the resumed agent MCP servers and tears the connection down at teardown (2.R)', async () => { + // The snapshot persists the author's `mcp_servers` (not the baked grant), so resume RE-connects them fresh + // each time and OWNS the new connection — runReplLoop's finally must close it. Distinct conns/counters for + // the seed vs the resume isolate the resume teardown. + const store = createSessionStore(client.db); + const agentPath = join(cwd, 'mcp.agent.yaml'); + writeFileSync(agentPath, MCP_AGENT_YAML); + + const seed = mcpConn(); + const seedBuild: typeof buildChatSession = (o) => + buildChatSession({ + ...o, + startMcpClient: () => + realStartMcpClient([{ id: 'fs', open: () => Promise.resolve(seed.conn) }]), + }); + expect( + await chatCommand( + { agent: agentPath }, + { ...freshDeps(['hello', '/exit'], [textTurn('hi')], store), buildSession: seedBuild }, + ), + ).toBe(EXIT_CODES.chatEnded); + + const resume = mcpConn(); + const resumeBuild: typeof buildResumedChatSession = (o) => + buildResumedChatSession({ + ...o, + startMcpClient: () => + realStartMcpClient([{ id: 'fs', open: () => Promise.resolve(resume.conn) }]), + }); + const { d } = resumeDeps(['again', '/exit'], [textTurn('more')], store); + expect( + await chatResumeCommand({ sessionId: 'id-0' }, { ...d, buildResumedSession: resumeBuild }), + ).toBe(EXIT_CODES.chatEnded); + expect(resume.closed()).toBe(1); // the RESUMED session's connection torn down once at teardown + }); }); describe('drivePlain', () => { diff --git a/apps/cli/src/engine/mcp-servers.ts b/apps/cli/src/engine/mcp-servers.ts index 41f2a9de..8661a419 100644 --- a/apps/cli/src/engine/mcp-servers.ts +++ b/apps/cli/src/engine/mcp-servers.ts @@ -397,7 +397,9 @@ export async function connectWorkflowMcp( throw new CliError( 'invalid_invocation', `MCP server '${ref.id}' is declared with conflicting settings by more than one agent — ` + - `give the distinct servers distinct ids.`, + `give the distinct servers distinct ids. (A by-name 'ref' uses the registration name sanitized ` + + `to the [A-Za-z0-9_-] charset, so two different names can collapse to the same id — if that is the ` + + `cause, make the registration names charset-distinct.)`, ); } } @@ -407,15 +409,22 @@ export async function connectWorkflowMcp( const configs = resolveServerConfigs([...byId.values()], opts.cwd, opts.resolveSecret); const client = await startMcpClientFailLoud(configs, opts.startMcpClient); - // Augment each inline agent's grant with ONLY its own servers' discovered ids (a `$ref` entry passes through). - const agents = (def.workflow.agents ?? []).map((entry) => - isInlineAgent(entry) ? withWorkflowMcpGrant(entry, client.toolIdsByServer) : entry, - ); - const workflow: WorkflowDefinition = { - ...def, - workflow: { ...def.workflow, agents }, - }; - return { client, workflow }; + try { + // Augment each inline agent's grant with ONLY its own servers' discovered ids (a `$ref` entry passes through). + const agents = (def.workflow.agents ?? []).map((entry) => + isInlineAgent(entry) ? withWorkflowMcpGrant(entry, client.toolIdsByServer) : entry, + ); + const workflow: WorkflowDefinition = { + ...def, + workflow: { ...def.workflow, agents }, + }; + return { client, workflow }; + } catch (err) { + // The connection is live but assembling the augmented workflow failed — tear it down so a spawned child / + // open socket never leaks past this throw (uniform all-or-nothing with the self-cleaning chat builders). + await client.close(); + throw err; + } } /** True for an inline agent definition (carries an `id`), false for a `{ $ref }` external reference. */ diff --git a/apps/cli/src/harness/mcp-stdio.e2e.test.ts b/apps/cli/src/harness/mcp-stdio.e2e.test.ts index ca7e6d75..3677fecf 100644 --- a/apps/cli/src/harness/mcp-stdio.e2e.test.ts +++ b/apps/cli/src/harness/mcp-stdio.e2e.test.ts @@ -43,12 +43,17 @@ describe('inbound MCP — real stdio spawn (2.R Step 5)', () => { const client = await connectAgentMcp([echoServer()], { cwd: process.cwd() }); expect(client).toBeDefined(); try { - // Discovery: both fixture tools are namespaced `mcp_{server}_{tool}`, grouped under the server id. + // Discovery: every fixture tool is namespaced `mcp_{server}_{tool}`, grouped under the server id. expect([...(client!.toolIdsByServer.get('echo') ?? [])].sort()).toEqual([ 'mcp_echo_add', 'mcp_echo_echo', + 'mcp_echo_whoami', + ]); + expect(client!.toolDefs.map((d) => d.id).sort()).toEqual([ + 'mcp_echo_add', + 'mcp_echo_echo', + 'mcp_echo_whoami', ]); - expect(client!.toolDefs.map((d) => d.id).sort()).toEqual(['mcp_echo_add', 'mcp_echo_echo']); expect(client!.skipped).toEqual([]); // A real tools/call over the spawned child round-trips (echo returns its text verbatim). @@ -101,6 +106,7 @@ describe('inbound MCP — real stdio spawn (2.R Step 5)', () => { expect([...(runtime!.client.toolIdsByServer.get('echo') ?? [])].sort()).toEqual([ 'mcp_echo_add', 'mcp_echo_echo', + 'mcp_echo_whoami', ]); // The run path unions the agent's declared grant with ITS server's discovered tool ids. @@ -110,6 +116,7 @@ describe('inbound MCP — real stdio spawn (2.R Step 5)', () => { expect([...(augmented.tools ?? [])].sort()).toEqual([ 'mcp_echo_add', 'mcp_echo_echo', + 'mcp_echo_whoami', 'read_file', ]); @@ -125,4 +132,30 @@ describe('inbound MCP — real stdio spawn (2.R Step 5)', () => { await runtime!.client.close(); } }); + + it('chat host: resolves {{secrets.*}} into the spawned child env (the last hop of the secret-custody chain)', async () => { + // The secret custody chain end-to-end against a REAL process: a `{{secrets.t}}` env placeholder is resolved + // by the injected resolver, the host injects it into the spawned child's env, and the child's `whoami` tool + // round-trips exactly that value back — proving the resolved secret reaches the process (and only there). + const server: McpServerRef = { + id: 'echo', + transport: 'stdio', + command: process.execPath, + args: [FIXTURE], + env: { MCP_FIXTURE_TOKEN: '{{secrets.t}}' }, + }; + const client = await connectAgentMcp([server], { + cwd: process.cwd(), + resolveSecret: (name) => (name === 't' ? 'SENTINEL-abc123' : ''), + }); + expect(client).toBeDefined(); + try { + const who = asResult( + await client!.capability.call({ server: 'echo', tool: 'whoami', args: {} }), + ); + expect(who.content).toEqual([{ type: 'text', text: 'SENTINEL-abc123' }]); + } finally { + await client!.close(); + } + }); }); diff --git a/docs/reference/contracts/config-spec.md b/docs/reference/contracts/config-spec.md index c892eb19..7781dad0 100644 --- a/docs/reference/contracts/config-spec.md +++ b/docs/reference/contracts/config-spec.md @@ -73,9 +73,9 @@ name = "filesystem" transport = "stdio" # stdio | http | websocket command = "npx -y @modelcontextprotocol/server-filesystem" args = ["--root", "~/projects"] -autostart = true +autostart = true # accepted, reserved for a future always-on pool — NOT acted on in 2.R (a server connects on demand) # url = "https://host/mcp" # for transport = http (Streamable HTTP); a `websocket` server uses wss:// -# env = { TOKEN = "..." } +# env = { TOKEN = "..." } # stdio only — injected into the spawned child; rejected on a network transport (header-auth is a follow-up) # allow_local_endpoint = true # opt into a private/loopback url (network transports only, ADR-0053 §3) ``` diff --git a/docs/reference/shared-core/mcp-integration.md b/docs/reference/shared-core/mcp-integration.md index 07157335..30d265d4 100644 --- a/docs/reference/shared-core/mcp-integration.md +++ b/docs/reference/shared-core/mcp-integration.md @@ -44,7 +44,7 @@ mcp_servers: transport: stdio # stdio | http | websocket (sse = deprecated alias of http) command: npx # stdio: the server binary args: ['-y', '@modelcontextprotocol/server-github'] - env: # env vars injected into the server process + env: # stdio only: env vars injected into the spawned server process GITHUB_TOKEN: '{{secrets.github_token}}' # resolved from the isolated mcp-secret:* keychain (§6) - id: docs transport: http # http (Streamable HTTP) / websocket use `url` instead of `command` @@ -99,6 +99,6 @@ This is also how the `mcp_call` workflow **trigger** works: a workflow with `tri ## Security - **MCP server URLs are SSRF-guarded ([ADR-0029](../../decisions/0029-tool-policy-hardening.md)).** A declared MCP `url` is validated against the **same** vetted range-block as a provider base URL and the `http_request` tool — private/loopback/link-local/metadata ranges (`127.0.0.0/8`, `::1`, `10/8`, `172.16/12`, `192.168/16`, `169.254/16`) are rejected, and remote hosts must use `https`/`wss`, **unless the user explicitly opts into a local endpoint** (the per-server `allow_local_endpoint` flag). A `http://localhost`/loopback `url` is exactly such a local endpoint and requires that explicit opt-in; the opt-in permits exactly the **authored `host:port`** (and plaintext for it). 2.R ships this as a **pre-connect floor** validating the authored host — a hostname that DNS-resolves to a private IP, or a redirect to one, is the residual window the connect-by-validated-IP dialer (per-hop re-validation against the authored `host:port`) closes; tracked in [../../roadmap/deferred-tasks.md](../../roadmap/deferred-tasks.md) ([ADR-0053](../../decisions/0053-mcp-network-transport-egress-security.md) §2). The one SSRF primitive is reused, never re-implemented — see [security-review.md](../../standards/security-review.md). -- MCP server credentials are injected from the secret store via the server's `env` (e.g. `{{secrets.github_token}}`) and are **never** written into the workflow file or any event payload. See [../desktop/keychain-and-secrets.md](../desktop/keychain-and-secrets.md). +- MCP server credentials are injected from the secret store via a **stdio** server's `env` (e.g. `{{secrets.github_token}}`, resolved from the isolated `mcp-secret:*` namespace) and are **never** written into the workflow file or any event payload. See [../desktop/keychain-and-secrets.md](../desktop/keychain-and-secrets.md). `env` applies to the spawned stdio child only — a network (`http`/`websocket`) transport has no process to inject into, so `env` is **rejected at parse** there (header-based auth for network MCP servers is a tracked follow-up, [../../roadmap/deferred-tasks.md](../../roadmap/deferred-tasks.md)). - Outbound (workflow-as-MCP) exposure is opt-in per workflow (only those listed in the adapter config are published). - All inbound MCP tool calls are schema-validated before dispatch, and tool inputs in events are sanitized — see [built-in-tools.md](built-in-tools.md) and [../contracts/sse-event-schema.md](../contracts/sse-event-schema.md). diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 625128d6..ee34cc2f 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -43,7 +43,8 @@ Severity is the review's verified rating. Check an item off in the PR that resol catch DNS rebinding or a public hostname resolving to a private IP. **Scope split (resolving the earlier "Phase 2" framing):** the **media** url-carrier mechanism is **pulled into 1.AF** on a new bytes-shaped media-egress capability ([ADR-0043](../decisions/0043-media-egress-failover-rematerialization-ssrf.md)); the **general tool/MCP** `EgressCapability.fetch` enforcement still lands when the desktop/CLI surface implements that fetch hook. *(packages/core/src/tools/types.ts; security-review.md; media → 1.AF/ADR-0043; tool/MCP → surface fetch hook)* - [ ] **MCP SDK network transport — upgrade to connect-by-validated-IP ([ADR-0053](../decisions/0053-mcp-network-transport-egress-security.md) §2).** 2.R ships **pre-connect host validation** as the floor for the `http` (Streamable HTTP) / `websocket` MCP transports — the `@modelcontextprotocol/sdk` opens its **own** socket, architecturally distinct from the `EgressCapability.fetch` hook above. When the SDK transport exposes an injectable `fetch`/dialer hook, upgrade to **connect-by-validated-IP**: resolve DNS → validate the IP against the shared range-block primitive → connect to that IP, re-validating on each redirect hop — closing the residual DNS-rebind window. **The dialer + redirect re-validation MUST enforce the authored `host:port`** (ADR-0053 §3 / SEC-EGRESS-3), not just the host: an `allow_local_endpoint` server is permitted exactly its declared `host:port`, so a resolved/redirected target on a *different* port of the same permitted-private host (`:6379`/`:5432`/`:22`/the Docker socket) must be re-blocked. (2.R's pre-connect floor is host:port-safe by construction — the SDK dials exactly the one authored url — so this constraint binds the dialer, not the floor.) Each MCP network mechanism gets a dedicated security-review pass when it lands. *(packages/mcp/src; ADR-0053 §2/§3; ADR-0043 mechanism)* - [ ] **MCP `stdio` spawn — import-trust/consent gate + `npx` dependency pinning ([ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §2).** Spawning a declared `stdio` MCP server runs arbitrary local code / an `npx`-installed package. 2.R treats a server declared in the user's **own** committed YAML as author trust; the **imported/shared untrusted workflow** case is out of baseline scope. When the import/share path matures, gate the first spawn of a server from an untrusted-provenance `.relavium.yaml` behind explicit consent, and pin the `npx` package version/integrity for the built-in auto-install servers. *(packages/mcp/src; apps/cli; ADR-0052 §2; ADR-0029 trust model)* -- [ ] **MCP host boundary — strip `McpConnectError.cause` from `--json` / event output (2.R Step 3, [ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §2).** The typed MCP errors' `message` is secret-free + surfaceable, but `.cause` (the wrapped SDK/spawn error) MAY carry the spawned command/args (never env/secret data). When the CLI host wires `@relavium/mcp` (Step 3+), a connect/discovery failure must surface as a typed `CliError` whose message is safe, and the host MUST NOT serialize `cause` verbatim into a `--json` envelope or a `RunEvent` — strip it at that boundary. The contract is documented in `@relavium/mcp` `errors.ts`. *(apps/cli; packages/mcp/src/errors.ts; 2.R Step 3)* +- [x] **MCP host boundary — strip `McpConnectError.cause` from `--json` / event output (2.R Step 3, [ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §2).** *Resolved in the 2.R Step 3 host wiring:* `startMcpClientFailLoud` (apps/cli/src/engine/mcp-servers.ts) wraps an `McpError` into a typed `CliError` whose message is the secret-free MCP summary with **no** `{ cause }` attached, and the top-level `--json` renderer (apps/cli/src/process/render-error.ts) serializes only `{ type, code, message }` — never `cause`. Regression-locked by `run.test.ts` (`expect(err.cause).toBeUndefined()`). *(apps/cli; packages/mcp/src/errors.ts; 2.R Step 3)* +- [ ] **MCP network transport — header-based auth ([ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §6).** 2.R injects `{{secrets.*}}` only into a **stdio** child's `env`; the network (`http`/`websocket`) specs carry only `{ url }`, so a network server's `env` is **rejected at parse** (fail-closed, never silently dropped). When network MCP servers need credentials, add a host-resolved auth-header field (e.g. `Authorization: 'Bearer {{secrets.}}'`) wired through the SDK transport's `requestInit`/headers, resolved from the same isolated `mcp-secret:*` namespace and never logged/serialized. *(packages/mcp/src/sdk-http.ts; apps/cli/src/engine/mcp-servers.ts; ADR-0052 §6)* - [ ] **MCP follow-ups (non-security).** A durable cross-invocation **tool-list cache** (mcp-integration.md ~1h per-`(command,args)`, with a transport-covering key) — 2.R re-runs discovery per process ([ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §3); and a generalized **`SecretResolver`** seam beyond the 2.R `mcp-secret:*` keychain namespace ([ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §6); and reconciling the `types.ts` `ToolId` "register dynamically" comment to "host-side assembled" when 2.R touches `packages/core`; and **mid-call abort propagation** — the engine's `AbortSignalLike` is not forwarded to the in-flight MCP `tools/call` (the SDK transport wants a DOM `AbortSignal`), so a turn cancel tears the connection down but does not cancel an in-flight call (`@relavium/mcp` `manager.ts`). *(packages/mcp/src; packages/core; Phase-3)* - [ ] **Streaming media triad (`media_start`/`media_delta`/`media_end`) — host-deferred ([ADR-0046](../decisions/0046-inline-media-out-via-generate-streaming-triad-deferred.md) §4).** 1.AG Section B delivers inline media-out through the non-streaming `generate()` path (the in-flight diff --git a/docs/standards/security-review.md b/docs/standards/security-review.md index 740c7b63..b92297a8 100644 --- a/docs/standards/security-review.md +++ b/docs/standards/security-review.md @@ -145,11 +145,19 @@ carrier fetched by `fetchMediaBytes`: binding rule — not a second home. See [ADR-0029](../decisions/0029-tool-policy-hardening.md) and [built-in-tools.md](../reference/shared-core/built-in-tools.md). -- **MCP server URLs.** *(Security tightening — ADR-0029(d); transport vocab reconciled by ADR-0052 §5.)* An MCP +- **MCP server URLs.** *(Security tightening — ADR-0029(d); transport vocab reconciled by ADR-0052 §5; network egress per ADR-0053.)* An MCP `http` (Streamable HTTP) / `websocket` server URL (`sse` is a deprecated alias of `http`) - is a second egress path that **injects secrets** into headers, so leaving it - scheme-checked-only while hardening `http_request` would be strictly worse. MCP server - URLs run the **same** SSRF range-primitive (no second parser). See + is a second egress path, so leaving it scheme-checked-only while hardening `http_request` + would be strictly worse. MCP server URLs run the **same** SSRF range-primitive (no second + parser): private/loopback/link-local/metadata hosts are rejected and a remote host must use + `https`/`wss`, **unless** the per-server `allow_local_endpoint` opt-in is set — which relaxes + the block AND permits plaintext for exactly the **authored `host:port`** (and never relaxes the + no-embedded-credentials check). 2.R ships this as a **pre-connect host floor** (the SDK opens its + own socket): a hostname that DNS-resolves to a private IP, or a redirect to one, is the residual + window the connect-by-validated-IP dialer closes — tracked in + [deferred-tasks.md](../roadmap/deferred-tasks.md) ([ADR-0053](../decisions/0053-mcp-network-transport-egress-security.md) §2/§3). + (A `stdio` server takes no URL; its secrets are injected into the spawned child's `env` — a network + transport has no process, so `env` is rejected there, header-auth being a follow-up.) See [mcp-integration.md](../reference/shared-core/mcp-integration.md) for the MCP contract and [ADR-0029](../decisions/0029-tool-policy-hardening.md) for the rationale. - **Media `url` carrier (multimodal — [ADR-0031](../decisions/0031-llm-seam-shape-amendment-multimodal-io.md) A7 / [ADR-0043](../decisions/0043-media-egress-failover-rematerialization-ssrf.md)) — a fourth path, now wired host-side.** diff --git a/packages/mcp/test-fixtures/echo-mcp-server.mjs b/packages/mcp/test-fixtures/echo-mcp-server.mjs index 75ccd3cf..cd2097c1 100644 --- a/packages/mcp/test-fixtures/echo-mcp-server.mjs +++ b/packages/mcp/test-fixtures/echo-mcp-server.mjs @@ -6,9 +6,11 @@ // `packages/mcp` because that is the only workspace where the SDK resolves (the seam owner); the SDK + // `node:*` confinement that the four `sdk-*.ts` adapters enforce does not apply to a standalone test process. // -// Two tools, both deterministic and offline: -// - `echo` → returns its `text` argument verbatim (the round-trip proof); -// - `add` → returns `a + b` as text (a second tool, so multi-tool discovery is asserted). +// Three tools, all deterministic and offline: +// - `echo` → returns its `text` argument verbatim (the round-trip proof); +// - `add` → returns `a + b` as text (a second tool, so multi-tool discovery is asserted); +// - `whoami` → returns the `MCP_FIXTURE_TOKEN` env var the host injected — proves a `{{secrets.*}}` value +// actually reaches the spawned child process (the last hop of the secret-custody chain). // // Fail-fast: any startup error exits non-zero so the spawning host's fail-loud connect surfaces it. import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; @@ -32,4 +34,12 @@ server.registerTool( ({ a, b }) => ({ content: [{ type: 'text', text: String(a + b) }] }), ); +server.registerTool( + 'whoami', + { + description: 'Return the MCP_FIXTURE_TOKEN env var the host injected into this child process.', + }, + () => ({ content: [{ type: 'text', text: process.env.MCP_FIXTURE_TOKEN ?? '' }] }), +); + await server.connect(new StdioServerTransport()); diff --git a/packages/shared/src/agent.test.ts b/packages/shared/src/agent.test.ts index 68fbf806..113ec8c1 100644 --- a/packages/shared/src/agent.test.ts +++ b/packages/shared/src/agent.test.ts @@ -257,6 +257,27 @@ describe('McpServerRefSchema', () => { ).toBe(false); }); + it('rejects `env` on a network transport (2.R injects env only into a stdio child; fail-closed not silent-drop)', () => { + // env carries `{{secrets.*}}`; a network transport spawns no child, so the host would silently discard it — + // rejecting at parse keeps the fail-closed posture (ADR-0052 §6). `env` on stdio still parses. + expect( + McpServerRefSchema.safeParse({ + id: 'docs', + transport: 'http', + url: 'https://docs.example/mcp', + env: { TOKEN: '{{secrets.gh}}' }, + }).success, + ).toBe(false); + expect( + McpServerRefSchema.safeParse({ + id: 'fs', + transport: 'stdio', + command: 'npx', + env: { TOKEN: '{{secrets.gh}}' }, + }).success, + ).toBe(true); + }); + describe('by-name `ref` form (ADR-0052 §5)', () => { it('accepts a bare { ref } and { ref, tools_allowlist } (the registration provides the connection)', () => { expect(McpServerRefSchema.safeParse({ ref: 'github' }).success).toBe(true); diff --git a/packages/shared/src/agent.ts b/packages/shared/src/agent.ts index 143fa621..39b63866 100644 --- a/packages/shared/src/agent.ts +++ b/packages/shared/src/agent.ts @@ -163,12 +163,26 @@ export const McpServerRefSchema = z }); } } - if (ref.transport !== 'stdio' && !ref.url) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `url is required for the '${ref.transport}' transport`, - path: ['url'], - }); + if (ref.transport !== 'stdio') { + if (!ref.url) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `url is required for the '${ref.transport}' transport`, + path: ['url'], + }); + } + // A network transport spawns no child process, so `env` (and its `{{secrets.*}}`) has nowhere to be + // injected — 2.R wires `env` ONLY into the stdio spawn. Reject it loud rather than silently dropping what + // is almost always an auth secret (the fail-closed posture, ADR-0052 §6); network header-auth is a tracked + // follow-up (deferred-tasks.md). Mirrors the stdio `url` / `allow_local_endpoint` guards above. + if (ref.env !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'env is not used by a network transport — it is injected only into a stdio server process (network header-auth is a follow-up)', + path: ['env'], + }); + } } if (ref.url !== undefined) { // Per-transport scheme: `http`/`sse` are HTTP(S), `websocket` is WS(S) (stdio carries no url). diff --git a/packages/shared/src/config.test.ts b/packages/shared/src/config.test.ts index 4b76144c..a6b1b1a5 100644 --- a/packages/shared/src/config.test.ts +++ b/packages/shared/src/config.test.ts @@ -118,6 +118,24 @@ describe('config schemas', () => { ).toBe(true); }); + it('rejects `env` on a network MCP registration (env is injected only into a stdio child — fail-closed)', () => { + // Mirrors the inline `McpServerRefSchema` guard: a committed network registration can't carry a dead `env` + // whose `{{secrets.*}}` would be silently discarded. + expect( + GlobalConfigSchema.safeParse({ + mcp_servers: [ + { name: 'docs', transport: 'http', url: 'https://docs.example/mcp', env: { TOKEN: 'x' } }, + ], + }).success, + ).toBe(false); + // env on a stdio registration is still accepted. + expect( + GlobalConfigSchema.safeParse({ + mcp_servers: [{ name: 'fs', transport: 'stdio', command: 'npx', env: { TOKEN: 'x' } }], + }).success, + ).toBe(true); + }); + it('rejects `allow_local_endpoint` on a stdio MCP registration (network-only — matches the inline ref schema)', () => { // The inline `McpServerRefSchema` (agent.ts) rejects the network-only flag on stdio; the registration // schema must agree, so a committed config can't carry a dead opt-in that an equivalent inline entry refuses. diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index d99f3de6..9e5c1672 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -59,12 +59,26 @@ export const McpServerRegistrationSchema = z path: ['allow_local_endpoint'], }); } - if (server.transport !== 'stdio' && !server.url) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `url is required for the '${server.transport}' transport`, - path: ['url'], - }); + if (server.transport !== 'stdio') { + if (!server.url) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `url is required for the '${server.transport}' transport`, + path: ['url'], + }); + } + // A network registration spawns no child process, so `env` (and its `{{secrets.*}}`) has nowhere to be + // injected — 2.R wires `env` ONLY into the stdio spawn. Reject it loud rather than silently dropping an + // auth secret (fail-closed, ADR-0052 §6); network header-auth is a tracked follow-up. Mirrors the inline + // `McpServerRefSchema` guard so a committed registration can't carry a dead network `env`. + if (server.env !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'env is not used by a network transport — it is injected only into a stdio server process (network header-auth is a follow-up)', + path: ['env'], + }); + } } if (server.url !== undefined) { // SSRF guard: a registered url must use the transport's scheme — reject file:, javascript:, etc. From da734bc75e7f1e655a80e2cfd5f7c72a8d60420c Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 27 Jun 2026 21:07:22 +0300 Subject: [PATCH 19/24] =?UTF-8?q?fix(cli,shared,docs):=202.R=20final=20hol?= =?UTF-8?q?istic=20Sonnet=20review=20=E2=80=94=20defense-in-depth=20+=20ss?= =?UTF-8?q?e=20coverage=20+=20message=20clarity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the ten confirmed findings from the final holistic Sonnet review of the inbound-MCP lane (all LOW; two further findings adversarially rejected as non-issues). These harden the edges the Opus pass left. - resolveServerConfigs now rejects a network `env` at the HOST boundary too (defense-in-depth below the schema) and dispatches transports exhaustively (a `never` guard, so a future enum member can't silently route to Streamable HTTP) — with a matching host-reject test. - buildChildEnv distinguishes its two failure causes: a correctly-written `{{secrets.X}}` with NO resolver wired now says "no MCP secret resolver is wired" (a wiring gap), not "unsupported interpolation" (a syntax red herring); the secret name is still never echoed. Test updated to the accurate message. - agent.ts: a stray `url` on a stdio transport no longer double-issues (the scheme/credentials pass is now network-only — a stdio url is already rejected outright). - Tests: env-on-network reject extended to all three network transports (http / sse / websocket); the SSRF floor matrix gains `sse` accept+reject cases; a connectWorkflowMcp dedup test covers an identical `{{secrets.*}}` env (shared connection) and a divergent one (fail loud WITHOUT leaking the placeholder); the e2e secret-custody case now asserts `whoami` is admitted (not skipped) before the round-trip; the custody unit test awaits `open()` instead of `void`. - Docs: the env-rejection note names the `sse` alias in both mcp-integration.md and security-review.md. Refs: ADR-0052, ADR-0053, ADR-0029 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/engine/mcp-servers.test.ts | 75 +++++++++++++++++-- apps/cli/src/engine/mcp-servers.ts | 43 ++++++++--- apps/cli/src/harness/mcp-stdio.e2e.test.ts | 5 ++ docs/reference/shared-core/mcp-integration.md | 2 +- docs/standards/security-review.md | 3 +- packages/shared/src/agent.test.ts | 23 +++--- packages/shared/src/agent.ts | 6 +- 7 files changed, 128 insertions(+), 29 deletions(-) diff --git a/apps/cli/src/engine/mcp-servers.test.ts b/apps/cli/src/engine/mcp-servers.test.ts index fde54118..d7c0f263 100644 --- a/apps/cli/src/engine/mcp-servers.test.ts +++ b/apps/cli/src/engine/mcp-servers.test.ts @@ -92,6 +92,18 @@ describe('resolveServerConfigs', () => { expect(() => resolveServerConfigs([bad], '/work')).toThrow(/requires a 'command'/); }); + it('rejects `env` on a network ref at the host boundary (defense-in-depth below the schema)', () => { + // The schema already rejects network `env`; this re-asserts at the host so a programmatic caller that + // bypassed the schema fails loud rather than silently discarding what is almost always an auth secret. + const bad: McpServerRef = { + id: 'docs', + transport: 'http', + url: 'https://docs.example/mcp', + env: { TOKEN: '{{secrets.gh}}' }, + }; + expect(() => resolveServerConfigs([bad], '/work')).toThrow(/'env' is not used by a network/); + }); + it('rejects an env value carrying a {{…}} marker when NO secret resolver is wired', () => { try { resolveServerConfigs([stdioRef({ env: { TOKEN: '{{secrets.gh}}' } })], '/work'); @@ -110,7 +122,7 @@ describe('resolveServerConfigs', () => { ).not.toThrow(); }); - it('carries the RESOLVED secret into the spawn-spec env (and only there) — ties resolver → buildChildEnv → open', () => { + it('carries the RESOLVED secret into the spawn-spec env (and only there) — ties resolver → buildChildEnv → open', async () => { // The genuine custody tie: inject a spy `openConnection`, invoke the config's `open()`, and assert the // resolved value reached the spawn boundary's `env`. In production that spec.env is the ONLY place the value // flows (sdk-stdio.ts `env: {...spec.env}`); nothing else observes it. @@ -131,7 +143,7 @@ describe('resolveServerConfigs', () => { }, }, ); - void configs[0]!.open(); // invoke the spawn closure → calls the spy with the built spec + await configs[0]!.open(); // invoke the spawn closure → calls the spy with the built spec (await: surface a reject) expect(capturedSpec?.env).toEqual({ TOKEN: 'Bearer ghp_SENTINEL' }); // the resolved value reached the spawn env expect(capturedSpec?.command).toBe('my-server'); }); @@ -183,6 +195,9 @@ describe('SSRF floor (resolveServerConfigs network gate, 2.R Step 4c / ADR-0053) it('permits a private/loopback endpoint AND plaintext WITH allow_local_endpoint', () => { expect(build({ url: 'http://localhost:4000/mcp', allow_local_endpoint: true })).toHaveLength(1); + expect( + build({ transport: 'sse', url: 'http://127.0.0.1:4000/sse', allow_local_endpoint: true }), + ).toHaveLength(1); // the sse alias runs the same floor as http expect( build({ transport: 'websocket', url: 'ws://127.0.0.1:4000/ws', allow_local_endpoint: true }), ).toHaveLength(1); @@ -193,6 +208,9 @@ describe('SSRF floor (resolveServerConfigs network gate, 2.R Step 4c / ADR-0053) expect(() => build({ url: 'http://api.example/mcp', allow_local_endpoint: true })).toThrow( /must use https\/wss/, ); + expect(() => build({ transport: 'sse', url: 'http://api.example/sse' })).toThrow( + /must use https\/wss/, + ); // the sse alias is gated identically expect(() => build({ transport: 'websocket', url: 'ws://api.example/ws' })).toThrow( /must use https\/wss/, ); @@ -242,10 +260,19 @@ describe('buildChildEnv (secret interpolation, 2.R Step 4)', () => { } }); - it('rejects a {{secrets.…}} when no resolver is wired (the value is never passed literally)', () => { - expect(() => buildChildEnv('fs', { TOKEN: '{{secrets.gh}}' })).toThrow( - /unsupported interpolation/, - ); + it('rejects a {{secrets.…}} when no resolver is wired with a WIRING-GAP message (not a syntax red herring)', () => { + // A correctly-written `{{secrets.gh}}` that fails only because no resolver was wired must say exactly that — + // "no MCP secret resolver is wired" — not "unsupported interpolation" (which would mislead the operator into + // suspecting their syntax). The key is named; the secret name is never echoed. + try { + buildChildEnv('fs', { TOKEN: '{{secrets.gh}}' }); + expect.unreachable('a {{secrets.…}} with no resolver must throw'); + } catch (err) { + expect(isCliError(err) && err.code).toBe('invalid_invocation'); + expect((err as Error).message).toContain('TOKEN'); // names the key + expect((err as Error).message).toContain('no MCP secret resolver is wired'); + expect((err as Error).message).not.toContain('secrets.gh'); // never echo the secret name + } }); it('does NOT false-reject a resolved secret whose VALUE itself contains "{{" (scan the pre-substitution value)', () => { @@ -444,6 +471,42 @@ describe('connectWorkflowMcp (run path)', () => { ).rejects.toThrow(/conflicting settings/); }); + it('shares one connection for an identical env (incl. {{secrets.*}}) and fails loud — without leaking the value — on a divergent env', async () => { + // `env` is part of the dedup fingerprint, so an identical placeholder shares one connection; a divergent one + // fails loud. The conflict error names ONLY the server id — the `{{secrets.*}}` placeholder must never surface. + const identical = wf( + [ + ' - { id: scanner, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ id: fs, transport: stdio, command: x, env: { TOKEN: "{{secrets.gh}}" } }] }', + ' - { id: writer, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ id: fs, transport: stdio, command: x, env: { TOKEN: "{{secrets.gh}}" } }] }', + '', + ].join('\n'), + ); + const runtime = await connectWorkflowMcp(identical, { + cwd: '/w', + startMcpClient: fakeStart(new Map([['fs', ['mcp_fs_read']]])), + resolveSecret: () => 'unused', // never invoked: the injected client ignores the spawn closures + }); + expect(runtime).toBeDefined(); // identical env ⇒ one shared connection (no conflict) + + const divergent = wf( + [ + ' - { id: scanner, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ id: fs, transport: stdio, command: x, env: { TOKEN: "{{secrets.gh}}" } }] }', + ' - { id: writer, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ id: fs, transport: stdio, command: x, env: { TOKEN: "{{secrets.OTHER}}" } }] }', + '', + ].join('\n'), + ); + await expect( + connectWorkflowMcp(divergent, { cwd: '/w', startMcpClient: fakeStart(new Map()) }), + ).rejects.toThrow(/conflicting settings/); + // The placeholder must NOT surface in the operator-facing conflict message. + await connectWorkflowMcp(divergent, { cwd: '/w', startMcpClient: fakeStart(new Map()) }).catch( + (err: unknown) => { + expect((err as Error).message).not.toContain('secrets.gh'); + expect((err as Error).message).not.toContain('secrets.OTHER'); + }, + ); + }); + it('fails loud when two agents share a server id but DIFFER on allow_local_endpoint (no silent opt-in sharing)', async () => { // The SSRF opt-in is part of the server identity (ADR-0053 §3): one agent's allow_local_endpoint must NOT be // silently inherited by another sharing the id — the divergent pair fails loud. diff --git a/apps/cli/src/engine/mcp-servers.ts b/apps/cli/src/engine/mcp-servers.ts index 8661a419..1a0d697a 100644 --- a/apps/cli/src/engine/mcp-servers.ts +++ b/apps/cli/src/engine/mcp-servers.ts @@ -191,24 +191,42 @@ export function resolveServerConfigs( continue; } - // Network transport (http | sse | websocket). The schema guarantees a `url`; re-assert defensively. + // Network transport (http | sse | websocket). The schema guarantees a `url` and forbids `env` here; re-assert + // both defensively so a programmatic caller that bypassed the schema fails loud rather than silently dropping. if (ref.url === undefined) { throw new CliError( 'invalid_invocation', `MCP server '${serverId}': the '${ref.transport}' transport requires a 'url'.`, ); } + if (ref.env !== undefined) { + // `env` (and its `{{secrets.*}}`) is injected only into a stdio child; a network transport has no process, + // so dropping it silently would discard an auth secret. Mirror the schema guard at the host boundary. + throw new CliError( + 'invalid_invocation', + `MCP server '${serverId}': 'env' is not used by a network transport — it is injected only into a stdio child.`, + ); + } const url = ref.url; const transport = ref.transport; // The SSRF pre-connect floor — rejects a private/loopback/link-local host (unless opted in) and a plaintext // remote (ADR-0053). The connect-by-validated-IP dialer upgrade (DNS-rebind) is the tracked follow-up. assertSafeNetworkEndpoint(serverId, url, ref.allow_local_endpoint === true); - const open = - transport === 'websocket' - ? () => openWs(serverId, { url }) - : transport === 'sse' - ? () => openSse(serverId, { url }) - : () => openHttp(serverId, { url }); // 'http' (Streamable HTTP) + // Exhaustive transport dispatch — a future transport added to the schema enum trips the `never` guard at + // compile time rather than silently routing to Streamable HTTP. + let open: () => Promise; + if (transport === 'http') + open = () => openHttp(serverId, { url }); // Streamable HTTP + else if (transport === 'sse') + open = () => openSse(serverId, { url }); // legacy HTTP+SSE alias of `http` + else if (transport === 'websocket') open = () => openWs(serverId, { url }); + else { + const unsupported: never = transport; + throw new CliError( + 'invalid_invocation', + `MCP server '${serverId}': unsupported transport '${String(unsupported)}'.`, + ); + } configs.push({ id: serverId, ...allowlist, open }); } return configs; @@ -333,10 +351,17 @@ export function buildChildEnv( resolveSecret === undefined ? value : value.replace(SECRET_PLACEHOLDER, ''); if (withoutSecretRefs.includes('{{')) { // Never pass a placeholder to the server as a literal. The KEY is named, never the value (a resolved - // secret must not surface), and never the resolved value either. + // secret must not surface), and never the resolved value either. Distinguish the two causes: a + // correctly-written `{{secrets.X}}` with NO resolver wired (a host wiring gap) vs an unsupported + // placeholder kind (`{{env.X}}`, malformed) — so the operator gets actionable guidance, not a syntax red + // herring. The `{{secrets.…}}` test below the un-substituted detection tells them apart. + const looksLikeSecretRef = resolveSecret === undefined && SECRET_PLACEHOLDER.test(value); + SECRET_PLACEHOLDER.lastIndex = 0; // `/g` test() advances lastIndex — reset before the substitution below throw new CliError( 'invalid_invocation', - `MCP server '${serverId}': unsupported interpolation in env '${key}' — only {{secrets.}} is supported.`, + looksLikeSecretRef + ? `MCP server '${serverId}': env '${key}' uses {{secrets.}} but no MCP secret resolver is wired.` + : `MCP server '${serverId}': unsupported interpolation in env '${key}' — only {{secrets.}} is supported.`, ); } env[key] = diff --git a/apps/cli/src/harness/mcp-stdio.e2e.test.ts b/apps/cli/src/harness/mcp-stdio.e2e.test.ts index 3677fecf..8990bf72 100644 --- a/apps/cli/src/harness/mcp-stdio.e2e.test.ts +++ b/apps/cli/src/harness/mcp-stdio.e2e.test.ts @@ -150,6 +150,11 @@ describe('inbound MCP — real stdio spawn (2.R Step 5)', () => { }); expect(client).toBeDefined(); try { + // The env injection must not perturb discovery: `whoami` is admitted as a callable LLM tool (not skipped), + // so the round-trip below proves custody, not merely the direct-dispatch path bypassing the grant. + expect(client!.skipped).toEqual([]); + expect(client!.toolDefs.map((d) => d.id)).toContain('mcp_echo_whoami'); + const who = asResult( await client!.capability.call({ server: 'echo', tool: 'whoami', args: {} }), ); diff --git a/docs/reference/shared-core/mcp-integration.md b/docs/reference/shared-core/mcp-integration.md index 30d265d4..560373fa 100644 --- a/docs/reference/shared-core/mcp-integration.md +++ b/docs/reference/shared-core/mcp-integration.md @@ -99,6 +99,6 @@ This is also how the `mcp_call` workflow **trigger** works: a workflow with `tri ## Security - **MCP server URLs are SSRF-guarded ([ADR-0029](../../decisions/0029-tool-policy-hardening.md)).** A declared MCP `url` is validated against the **same** vetted range-block as a provider base URL and the `http_request` tool — private/loopback/link-local/metadata ranges (`127.0.0.0/8`, `::1`, `10/8`, `172.16/12`, `192.168/16`, `169.254/16`) are rejected, and remote hosts must use `https`/`wss`, **unless the user explicitly opts into a local endpoint** (the per-server `allow_local_endpoint` flag). A `http://localhost`/loopback `url` is exactly such a local endpoint and requires that explicit opt-in; the opt-in permits exactly the **authored `host:port`** (and plaintext for it). 2.R ships this as a **pre-connect floor** validating the authored host — a hostname that DNS-resolves to a private IP, or a redirect to one, is the residual window the connect-by-validated-IP dialer (per-hop re-validation against the authored `host:port`) closes; tracked in [../../roadmap/deferred-tasks.md](../../roadmap/deferred-tasks.md) ([ADR-0053](../../decisions/0053-mcp-network-transport-egress-security.md) §2). The one SSRF primitive is reused, never re-implemented — see [security-review.md](../../standards/security-review.md). -- MCP server credentials are injected from the secret store via a **stdio** server's `env` (e.g. `{{secrets.github_token}}`, resolved from the isolated `mcp-secret:*` namespace) and are **never** written into the workflow file or any event payload. See [../desktop/keychain-and-secrets.md](../desktop/keychain-and-secrets.md). `env` applies to the spawned stdio child only — a network (`http`/`websocket`) transport has no process to inject into, so `env` is **rejected at parse** there (header-based auth for network MCP servers is a tracked follow-up, [../../roadmap/deferred-tasks.md](../../roadmap/deferred-tasks.md)). +- MCP server credentials are injected from the secret store via a **stdio** server's `env` (e.g. `{{secrets.github_token}}`, resolved from the isolated `mcp-secret:*` namespace) and are **never** written into the workflow file or any event payload. See [../desktop/keychain-and-secrets.md](../desktop/keychain-and-secrets.md). `env` applies to the spawned stdio child only — a network (`http`, its deprecated `sse` alias, or `websocket`) transport has no process to inject into, so `env` is **rejected at parse** there (header-based auth for network MCP servers is a tracked follow-up, [../../roadmap/deferred-tasks.md](../../roadmap/deferred-tasks.md)). - Outbound (workflow-as-MCP) exposure is opt-in per workflow (only those listed in the adapter config are published). - All inbound MCP tool calls are schema-validated before dispatch, and tool inputs in events are sanitized — see [built-in-tools.md](built-in-tools.md) and [../contracts/sse-event-schema.md](../contracts/sse-event-schema.md). diff --git a/docs/standards/security-review.md b/docs/standards/security-review.md index b92297a8..065de8fb 100644 --- a/docs/standards/security-review.md +++ b/docs/standards/security-review.md @@ -157,7 +157,8 @@ carrier fetched by `fetchMediaBytes`: window the connect-by-validated-IP dialer closes — tracked in [deferred-tasks.md](../roadmap/deferred-tasks.md) ([ADR-0053](../decisions/0053-mcp-network-transport-egress-security.md) §2/§3). (A `stdio` server takes no URL; its secrets are injected into the spawned child's `env` — a network - transport has no process, so `env` is rejected there, header-auth being a follow-up.) See + transport (`http`, its deprecated `sse` alias, or `websocket`) has no process, so `env` is rejected + there, header-auth being a follow-up.) See [mcp-integration.md](../reference/shared-core/mcp-integration.md) for the MCP contract and [ADR-0029](../decisions/0029-tool-policy-hardening.md) for the rationale. - **Media `url` carrier (multimodal — [ADR-0031](../decisions/0031-llm-seam-shape-amendment-multimodal-io.md) A7 / [ADR-0043](../decisions/0043-media-egress-failover-rematerialization-ssrf.md)) — a fourth path, now wired host-side.** diff --git a/packages/shared/src/agent.test.ts b/packages/shared/src/agent.test.ts index 113ec8c1..8a2b4ba5 100644 --- a/packages/shared/src/agent.test.ts +++ b/packages/shared/src/agent.test.ts @@ -257,17 +257,20 @@ describe('McpServerRefSchema', () => { ).toBe(false); }); - it('rejects `env` on a network transport (2.R injects env only into a stdio child; fail-closed not silent-drop)', () => { + it('rejects `env` on EVERY network transport (2.R injects env only into a stdio child; fail-closed not silent-drop)', () => { // env carries `{{secrets.*}}`; a network transport spawns no child, so the host would silently discard it — - // rejecting at parse keeps the fail-closed posture (ADR-0052 §6). `env` on stdio still parses. - expect( - McpServerRefSchema.safeParse({ - id: 'docs', - transport: 'http', - url: 'https://docs.example/mcp', - env: { TOKEN: '{{secrets.gh}}' }, - }).success, - ).toBe(false); + // rejecting at parse keeps the fail-closed posture (ADR-0052 §6) for http, its sse alias, AND websocket. + for (const net of [ + { transport: 'http' as const, url: 'https://docs.example/mcp' }, + { transport: 'sse' as const, url: 'https://docs.example/sse' }, + { transport: 'websocket' as const, url: 'wss://docs.example/ws' }, + ]) { + expect( + McpServerRefSchema.safeParse({ id: 'docs', ...net, env: { TOKEN: '{{secrets.gh}}' } }) + .success, + ).toBe(false); + } + // `env` on stdio still parses. expect( McpServerRefSchema.safeParse({ id: 'fs', diff --git a/packages/shared/src/agent.ts b/packages/shared/src/agent.ts index 39b63866..77197e44 100644 --- a/packages/shared/src/agent.ts +++ b/packages/shared/src/agent.ts @@ -184,8 +184,10 @@ export const McpServerRefSchema = z }); } } - if (ref.url !== undefined) { - // Per-transport scheme: `http`/`sse` are HTTP(S), `websocket` is WS(S) (stdio carries no url). + if (ref.url !== undefined && ref.transport !== 'stdio') { + // Per-transport scheme: `http`/`sse` are HTTP(S), `websocket` is WS(S). A stdio `url` is already rejected + // outright above (its scheme is irrelevant), so this scheme/credentials pass is network-only — otherwise a + // stray stdio url would double-issue ("url not used by stdio" AND "scheme invalid"). // This also blocks file:/javascript:/etc. as a side effect. let schemeOk: boolean; if (ref.transport === 'websocket') schemeOk = /^wss?:\/\//i.test(ref.url); From f2855cb0939eff3e5a2ee296da520306c91e169f Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 27 Jun 2026 21:55:29 +0300 Subject: [PATCH 20/24] =?UTF-8?q?fix(cli,shared,docs):=20PR=20#57=20review?= =?UTF-8?q?=20=E2=80=94=20teardown=20robustness,=20Sonar=20complexity,=20S?= =?UTF-8?q?SRF/schema=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the PR #57 maintainer review (verified each finding against current code; fixes minimal, two findings skipped as non-issues — see below). Resource-cleanup robustness (best-effort, attempt-all, preserve-primary): - session-host build + resume catches swallow the MCP close rejection so it can't mask the original construction/resume error. - agent-run finally: MCP teardown is best-effort (warns, never overrides the exit code). - chat: the build→loop window now tears down EVERY acquired resource (store + MCP) on a persister fault; runReplLoop's finally and the resume catch attempt all teardown steps (a reject in one never skips the next) via a shared closeQuietly/warnTeardown. - run: a nested try/finally guarantees the MCP client closes even if the db close throws. Sonar (Critical cognitive-complexity + minors): - agent.ts superRefine split into validateRefForm / validateStdioFields / validateNetworkFields (30 → well under 15); SAFE_MCP_URL dropped (now unreachable). - config.ts McpServerRegistrationSchema split into validateStdio/NetworkRegistration (16 → under 15) AND now rejects a stray `url` on a stdio registration (matching the inline McpServerRefSchema — closes the registration/inline asymmetry). - resolveServerConfigs extracts buildStdioConfig / buildNetworkConfig (19 → under 15), the network dispatch is a keyed lookup; entryServerId de-negates its condition; session-host hoists the fail-closed `?? {}` out of the spread. Security / correctness: - assertSafeNetworkEndpoint validates the scheme (http/https/ws/wss) FIRST, before the allow_local_endpoint relaxation, so an opt-in local endpoint can't wave through a file:/javascript: scheme (defense-in-depth, ADR-0053). - agent-run `--fixture` (cassette) is now fully offline: no live `[[mcp_servers]]` registrations and an env-only secret resolver (never the keychain). Tests + docs: - e2e: inject the spawn paths via JSON.stringify (Windows-safe YAML scalars) and replace the asResult cast + non-null assertions with a `defined()` narrowing + an `asserts` tool-result guard. - agent.test: ref-dedup cases (duplicate `ref`, and a `ref` colliding with an inline `id`); config.test: env-on-network for websocket + the new url-on-stdio rejection; env-message + keychain-fail tests narrow via isCliError instead of `as Error`. - config-spec.md uses the {{secrets.*}} placeholder; security-review.md trims the reference-domain restatement to a pointer, frames MCP as the pre-connect-floor-only exception, and aligns the outbound-paths summary; echo fixture imports node:process. Skipped (verified non-issues): the e2e path "YAML injection" beyond JSON.stringify (the values are test-controlled, not untrusted) and the connectSdkTransport "seam export" (it is internal to @relavium/mcp, never re-exported from index.ts). Refs: ADR-0052, ADR-0053, ADR-0029, ADR-0033 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/chat/session-host.ts | 12 +- apps/cli/src/commands/agent-run.ts | 17 +- apps/cli/src/commands/chat.ts | 50 +++-- apps/cli/src/commands/run.ts | 12 +- apps/cli/src/engine/mcp-servers.test.ts | 23 ++- apps/cli/src/engine/mcp-servers.ts | 159 ++++++++------- apps/cli/src/harness/mcp-stdio.e2e.test.ts | 110 +++++++---- apps/cli/src/secrets/mcp-secret.test.ts | 8 +- docs/reference/contracts/config-spec.md | 2 +- docs/standards/security-review.md | 35 ++-- .../mcp/test-fixtures/echo-mcp-server.mjs | 2 + packages/shared/src/agent.test.ts | 20 ++ packages/shared/src/agent.ts | 183 +++++++++--------- packages/shared/src/config.test.ts | 33 +++- packages/shared/src/config.ts | 144 ++++++++------ 15 files changed, 484 insertions(+), 326 deletions(-) diff --git a/apps/cli/src/chat/session-host.ts b/apps/cli/src/chat/session-host.ts index b09a7cae..f3d2da7e 100644 --- a/apps/cli/src/chat/session-host.ts +++ b/apps/cli/src/chat/session-host.ts @@ -156,8 +156,10 @@ function buildSessionRuntime( const bus = new RunEventBus({ now: () => new Date(opts.now()).toISOString() }); const providers = opts.providers ?? createProviderResolver(); const tools = mcp === undefined ? BUILTIN_TOOLS : [...BUILTIN_TOOLS, ...mcp.toolDefs]; - const host: ToolHost = - mcp === undefined ? (opts.toolHost ?? {}) : { ...(opts.toolHost ?? {}), mcp: mcp.capability }; + // `?? {}` is the deliberate fail-closed default (no capabilities); hoisted so the merge below doesn't spread a + // freshly-allocated empty object literal. + const baseHost: ToolHost = opts.toolHost ?? {}; + const host: ToolHost = mcp === undefined ? baseHost : { ...baseHost, mcp: mcp.capability }; const registry = createToolRegistry({ tools, host }); const governor = buildGovernorWiring(opts.chat, opts.onBudgetWarning); // The session event sink (1.W): a draft → bus → stamped sequenceNumber/timestamp. Hoisted so a SURFACE @@ -234,7 +236,8 @@ export async function buildChatSession(opts: BuildChatSessionOptions): Promise undefined); throw err; } } @@ -357,7 +360,8 @@ export async function buildResumedChatSession( }; } catch (err) { // Self-clean: a post-connect fault must not leak the just-spawned MCP children (see {@link buildChatSession}). - await mcp?.close(); + // Best-effort: a teardown rejection must NOT mask the original resume error (preserve the primary). + await mcp?.close().catch(() => undefined); throw err; } } diff --git a/apps/cli/src/commands/agent-run.ts b/apps/cli/src/commands/agent-run.ts index e3f29a70..7b856289 100644 --- a/apps/cli/src/commands/agent-run.ts +++ b/apps/cli/src/commands/agent-run.ts @@ -80,6 +80,7 @@ export async function agentRunCommand( // A `--fixture` replays a cassette (offline, no keychain) and takes precedence over any injected/real seam; // otherwise tests inject `providers`, and production resolves keys via the env/keychain (like `relavium run`). + const offline = args.fixture !== undefined; const providers = args.fixture === undefined ? (deps.providers ?? createProviderResolver(deps.io.env)) @@ -87,7 +88,8 @@ export async function agentRunCommand( // An unknown `` (path or id) throws a typed CliError here (exit 2), before any turn. The build is // async (2.R): it connects the agent's inline stdio `mcp_servers` (a connect failure is a fail-loud exit-2 - // CliError, cause stripped) before the one-shot turn runs. + // CliError, cause stripped) before the one-shot turn runs. In `--fixture` (cassette) mode the run must be + // FULLY offline: no `[[mcp_servers]]` registrations and an env-only secret resolver (never the keychain). const built = await (deps.buildSession ?? buildChatSession)({ chat: config.chat, agentRef: args.agent, @@ -96,8 +98,10 @@ export async function agentRunCommand( now, uuid, providers, - mcpSecretResolver: deps.mcpSecretResolver ?? createMcpSecretResolver(deps.io.env), - mcpRegistrations: config.mcpServers, + mcpSecretResolver: offline + ? createMcpSecretResolver(deps.io.env) + : (deps.mcpSecretResolver ?? createMcpSecretResolver(deps.io.env)), + mcpRegistrations: offline ? [] : config.mcpServers, }); // Render the live stream (NDJSON under --json, else the plain token/tool printer) and capture the turn // outcome — a classified turn failure completes with `session:turn_completed.error`, mapping to exit 1. @@ -132,7 +136,12 @@ export async function agentRunCommand( built.session.cancel(); // the session's terminal (session:cancelled) — closes the one-shot cleanly unsubscribe(); // Tear down the MCP connections (2.R) after the one-shot turn; present only when `mcp_servers` is declared. - await built.closeMcp?.(); + // Best-effort: a teardown rejection must NOT override the computed one-shot exit code (warn, don't throw). + await built.closeMcp?.().catch((e: unknown) => { + deps.io.writeErr( + `warning: MCP teardown failed: ${e instanceof Error ? e.message : String(e)}\n`, + ); + }); } return turnErrorCode === undefined ? EXIT_CODES.success : EXIT_CODES.workflowFailed; } diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index a8b44c2f..24ac1af3 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -43,6 +43,24 @@ export interface ChatCommandArgs { readonly agent: string | undefined; } +/** Surface a teardown failure as a stderr warning (never silently swallowed, never thrown — the primary + * command outcome must survive a cleanup fault). Shared by the chat command + resume teardown paths. */ +function warnTeardown(io: CliIo, label: string, err: unknown): void { + io.writeErr( + `warning: ${label} teardown failed: ${err instanceof Error ? err.message : String(err)}\n`, + ); +} + +/** Best-effort SYNC close: run it, but a reject/throw only warns — so one resource's failure never skips the + * next teardown step nor masks the primary outcome (attempt-all, preserve-primary). */ +function closeQuietly(io: CliIo, label: string, close: () => void): void { + try { + close(); + } catch (err) { + warnTeardown(io, label, err); + } +} + /** What an interactive driver receives — the command core's seam, so a driver never touches the session directly. */ export interface ChatDriveContext { /** @@ -156,11 +174,18 @@ export async function chatCommand(args: ChatCommandArgs, deps: ChatCommandDeps): // The session now OWNS the live MCP connections (built.closeMcp). `runReplLoop`'s finally is the steady-state // teardown, but the build→loop window (opening history.db can throw) runs first — guard it so a pre-loop fault // tears the connections down rather than orphaning the spawned children (ADR-0052 §2 teardown-on-terminal). + surfaceMcpSkipped(deps.io, built.mcpSkipped); + // Acquire the store first; on failure tear the MCP children down (best-effort — never mask the open error). let opened: OpenedSessionStore; - let persister: SessionPersister; try { - surfaceMcpSkipped(deps.io, built.mcpSkipped); opened = (deps.openSessionStore ?? openSessionStore)(homeDir); + } catch (err) { + await built.closeMcp?.().catch(() => undefined); + throw err; + } + // Then the persister; on failure tear down EVERY acquired resource (the store too) before rethrowing. + let persister: SessionPersister; + try { persister = createSessionPersister({ store: opened.store, handle: built.handle, @@ -171,7 +196,8 @@ export async function chatCommand(args: ChatCommandArgs, deps: ChatCommandDeps): uuid, }); } catch (err) { - await built.closeMcp?.(); + closeQuietly(deps.io, 'session store', () => opened.close()); + await built.closeMcp?.().catch(() => undefined); throw err; } @@ -272,9 +298,10 @@ export async function chatResumeCommand( } } catch (err) { // A pre-loop fault (not-found, no snapshot, build failure, or a post-build setup throw) must not strand the - // open db handle NOR the spawned MCP children — tear both down (closeMcp is idempotent + a no-op when unset). - await closeMcp?.(); - opened.close(); + // open db handle NOR the spawned MCP children — tear BOTH down (a reject in one must not skip the other), and + // never let a cleanup fault mask the primary error (best-effort; closeMcp is idempotent + a no-op when unset). + await closeMcp?.().catch((e: unknown) => warnTeardown(deps.io, 'MCP', e)); + closeQuietly(deps.io, 'session store', () => opened.close()); throw err; } @@ -386,11 +413,12 @@ async function runReplLoop(wiring: ReplWiring, deps: ChatReplDeps): Promise persister.close()); + closeQuietly(deps.io, 'session store', () => opened.close()); + await built.closeMcp?.().catch((e: unknown) => warnTeardown(deps.io, 'MCP', e)); } // `/exit`, `/cancel`, and an input EOF all END the chat session — the canonical chat-session-ended code. return EXIT_CODES.chatEnded; diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index 0043f94d..c0490355 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -239,9 +239,13 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr return outcomeToExitCode(outcome); } finally { - opened?.close(); - // Tear down the inbound MCP connections (2.R) at the run terminal — present only when an inline agent - // declared a server; idempotent. A teardown error must never mask the run outcome (closeAll swallows). - await mcpRuntime?.client.close(); + // Guarantee the MCP teardown runs EVEN IF the db close throws — a nested finally so neither resource leaks. + // Present only when an inline agent declared a server; idempotent. A teardown error must never mask the run + // outcome (closeAll swallows per-connection; the db close is best-effort here too). + try { + opened?.close(); + } finally { + await mcpRuntime?.client.close(); + } } } diff --git a/apps/cli/src/engine/mcp-servers.test.ts b/apps/cli/src/engine/mcp-servers.test.ts index d7c0f263..6ce83078 100644 --- a/apps/cli/src/engine/mcp-servers.test.ts +++ b/apps/cli/src/engine/mcp-servers.test.ts @@ -109,10 +109,11 @@ describe('resolveServerConfigs', () => { resolveServerConfigs([stdioRef({ env: { TOKEN: '{{secrets.gh}}' } })], '/work'); expect.unreachable('a {{…}} env value must throw'); } catch (err) { - expect(isCliError(err) && err.code).toBe('invalid_invocation'); + if (!isCliError(err)) throw err; // narrow to CliError (no cast); a non-CliError is an unexpected fault + expect(err.code).toBe('invalid_invocation'); // The error names the KEY, never the value — a placeholder is not a secret, but stay disciplined. - expect((err as Error).message).toContain('TOKEN'); - expect((err as Error).message).not.toContain('secrets.gh'); + expect(err.message).toContain('TOKEN'); + expect(err.message).not.toContain('secrets.gh'); } }); @@ -254,9 +255,10 @@ describe('buildChildEnv (secret interpolation, 2.R Step 4)', () => { buildChildEnv('fs', { HOST: '{{env.HOSTNAME}}' }, () => 'x'); expect.unreachable('an unsupported interpolation must throw'); } catch (err) { - expect(isCliError(err) && err.code).toBe('invalid_invocation'); - expect((err as Error).message).toContain('HOST'); // names the key - expect((err as Error).message).toContain('only {{secrets.}}'); + if (!isCliError(err)) throw err; // narrow to CliError (no cast) + expect(err.code).toBe('invalid_invocation'); + expect(err.message).toContain('HOST'); // names the key + expect(err.message).toContain('only {{secrets.}}'); } }); @@ -268,10 +270,11 @@ describe('buildChildEnv (secret interpolation, 2.R Step 4)', () => { buildChildEnv('fs', { TOKEN: '{{secrets.gh}}' }); expect.unreachable('a {{secrets.…}} with no resolver must throw'); } catch (err) { - expect(isCliError(err) && err.code).toBe('invalid_invocation'); - expect((err as Error).message).toContain('TOKEN'); // names the key - expect((err as Error).message).toContain('no MCP secret resolver is wired'); - expect((err as Error).message).not.toContain('secrets.gh'); // never echo the secret name + if (!isCliError(err)) throw err; // narrow to CliError (no cast) + expect(err.code).toBe('invalid_invocation'); + expect(err.message).toContain('TOKEN'); // names the key + expect(err.message).toContain('no MCP secret resolver is wired'); + expect(err.message).not.toContain('secrets.gh'); // never echo the secret name } }); diff --git a/apps/cli/src/engine/mcp-servers.ts b/apps/cli/src/engine/mcp-servers.ts index 1a0d697a..ede76f54 100644 --- a/apps/cli/src/engine/mcp-servers.ts +++ b/apps/cli/src/engine/mcp-servers.ts @@ -115,7 +115,7 @@ export function resolveMcpServerRef( * inline `id` (already charset-safe). */ function entryServerId(entry: McpServerRef): string | undefined { - return entry.ref !== undefined ? sanitizeServerSegment(entry.ref) : entry.id; + return entry.ref === undefined ? entry.id : sanitizeServerSegment(entry.ref); } /** Open a stdio MCP connection from a spawn spec — the real {@link openStdioConnection}, or a test spy. */ @@ -135,6 +135,11 @@ export interface ServerOpeners { readonly websocket?: (serverId: string, spec: WebSocketServerSpec) => Promise; } +/** The network transports — all take the same `{ url }` connect spec, so the dispatch is a keyed lookup. */ +type NetworkTransport = 'http' | 'sse' | 'websocket'; +type NetworkOpener = (serverId: string, spec: { readonly url: string }) => Promise; +type NetworkOpeners = Record; + /** * Map an agent's inline `mcp_servers` to {@link McpServerConfig}s, dispatching by transport — `stdio` spawns a * child (the declared `env` with `{{secrets.*}}` resolved), and `http` (Streamable HTTP) / `sse` (legacy @@ -150,9 +155,11 @@ export function resolveServerConfigs( openers: ServerOpeners = {}, ): McpServerConfig[] { const openStdio = openers.stdio ?? openStdioConnection; - const openHttp = openers.http ?? openHttpConnection; - const openSse = openers.sse ?? openSseConnection; - const openWs = openers.websocket ?? openWebSocketConnection; + const network: NetworkOpeners = { + http: openers.http ?? openHttpConnection, + sse: openers.sse ?? openSseConnection, + websocket: openers.websocket ?? openWebSocketConnection, + }; const configs: McpServerConfig[] = []; for (const ref of mcpServers ?? []) { // A by-name `ref` must be resolved to inline (id + transport) before reaching here (resolveMcpServerRef). @@ -162,74 +169,76 @@ export function resolveServerConfigs( `MCP server '${ref.ref ?? ref.id ?? '?'}': a by-name reference could not be resolved to a connection.`, ); } - const serverId = ref.id; // capture the narrowed id so it survives into the deferred `open` closure - const allowlist = - ref.tools_allowlist === undefined ? {} : { toolsAllowlist: ref.tools_allowlist }; + configs.push( + ref.transport === 'stdio' + ? buildStdioConfig(ref.id, ref, cwd, resolveSecret, openStdio) + : buildNetworkConfig(ref.id, ref.transport, ref, network), + ); + } + return configs; +} - if (ref.transport === 'stdio') { - // The schema's `superRefine` already guarantees `command` for a stdio transport; re-assert so the spawn - // spec is total without a non-null assertion (a defensive, typed failure rather than an undefined spawn). - if (ref.command === undefined) { - throw new CliError( - 'invalid_invocation', - `MCP server '${serverId}': a 'stdio' transport requires a 'command'.`, - ); - } - const command = ref.command; - const env = buildChildEnv(serverId, ref.env, resolveSecret); - configs.push({ - id: serverId, - ...allowlist, - open: () => - openStdio(serverId, { - command, - env, - cwd, - ...(ref.args === undefined ? {} : { args: ref.args }), - }), - }); - continue; - } +/** The per-server `tools_allowlist` projected onto the `McpServerConfig` shape (omitted when absent — never an + * explicit `undefined`, honoring exactOptionalPropertyTypes). */ +function toolsAllowlistFields(ref: McpServerRef): Pick { + return ref.tools_allowlist === undefined ? {} : { toolsAllowlist: ref.tools_allowlist }; +} - // Network transport (http | sse | websocket). The schema guarantees a `url` and forbids `env` here; re-assert - // both defensively so a programmatic caller that bypassed the schema fails loud rather than silently dropping. - if (ref.url === undefined) { - throw new CliError( - 'invalid_invocation', - `MCP server '${serverId}': the '${ref.transport}' transport requires a 'url'.`, - ); - } - if (ref.env !== undefined) { - // `env` (and its `{{secrets.*}}`) is injected only into a stdio child; a network transport has no process, - // so dropping it silently would discard an auth secret. Mirror the schema guard at the host boundary. - throw new CliError( - 'invalid_invocation', - `MCP server '${serverId}': 'env' is not used by a network transport — it is injected only into a stdio child.`, - ); - } - const url = ref.url; - const transport = ref.transport; - // The SSRF pre-connect floor — rejects a private/loopback/link-local host (unless opted in) and a plaintext - // remote (ADR-0053). The connect-by-validated-IP dialer upgrade (DNS-rebind) is the tracked follow-up. - assertSafeNetworkEndpoint(serverId, url, ref.allow_local_endpoint === true); - // Exhaustive transport dispatch — a future transport added to the schema enum trips the `never` guard at - // compile time rather than silently routing to Streamable HTTP. - let open: () => Promise; - if (transport === 'http') - open = () => openHttp(serverId, { url }); // Streamable HTTP - else if (transport === 'sse') - open = () => openSse(serverId, { url }); // legacy HTTP+SSE alias of `http` - else if (transport === 'websocket') open = () => openWs(serverId, { url }); - else { - const unsupported: never = transport; - throw new CliError( - 'invalid_invocation', - `MCP server '${serverId}': unsupported transport '${String(unsupported)}'.`, - ); - } - configs.push({ id: serverId, ...allowlist, open }); +/** Build the {@link McpServerConfig} for a `stdio` server — a fail-loud `command` check + the spawn closure + * carrying the resolved `env` ({@link buildChildEnv}). */ +function buildStdioConfig( + serverId: string, + ref: McpServerRef, + cwd: string, + resolveSecret: McpSecretResolver | undefined, + openStdio: OpenStdioConnection, +): McpServerConfig { + // The schema's `superRefine` already guarantees `command` for a stdio transport; re-assert so the spawn spec + // is total without a non-null assertion (a defensive, typed failure rather than an undefined spawn). + if (ref.command === undefined) { + throw new CliError( + 'invalid_invocation', + `MCP server '${serverId}': a 'stdio' transport requires a 'command'.`, + ); } - return configs; + const command = ref.command; + const env = buildChildEnv(serverId, ref.env, resolveSecret); + const args = ref.args; + return { + id: serverId, + ...toolsAllowlistFields(ref), + open: () => openStdio(serverId, { command, env, cwd, ...(args === undefined ? {} : { args }) }), + }; +} + +/** Build the {@link McpServerConfig} for a network server (`http`/`sse`/`websocket`) — a fail-loud `url`/`env` + * check, the SSRF floor, and the transport-dispatched connect closure. */ +function buildNetworkConfig( + serverId: string, + transport: NetworkTransport, + ref: McpServerRef, + openers: NetworkOpeners, +): McpServerConfig { + // The schema guarantees a `url` and forbids `env` on a network transport; re-assert both defensively so a + // programmatic caller that bypassed the schema fails loud rather than silently dropping (an `env` secret). + if (ref.url === undefined) { + throw new CliError( + 'invalid_invocation', + `MCP server '${serverId}': the '${transport}' transport requires a 'url'.`, + ); + } + if (ref.env !== undefined) { + throw new CliError( + 'invalid_invocation', + `MCP server '${serverId}': 'env' is not used by a network transport — it is injected only into a stdio child.`, + ); + } + const url = ref.url; + // The SSRF pre-connect floor — rejects a private/loopback/link-local host (unless opted in) and a plaintext + // remote (ADR-0053). The connect-by-validated-IP dialer upgrade (DNS-rebind) is the tracked follow-up. + assertSafeNetworkEndpoint(serverId, url, ref.allow_local_endpoint === true); + const open = openers[transport]; // `http` (Streamable HTTP) | `sse` (legacy HTTP+SSE alias) | `websocket` + return { id: serverId, ...toolsAllowlistFields(ref), open: () => open(serverId, { url }) }; } /** @@ -260,8 +269,18 @@ function assertSafeNetworkEndpoint(serverId: string, url: string, allowLocal: bo `MCP server '${serverId}': the url must not embed credentials (user:pass@…) — use env/keychain auth.`, ); } + // Validate the scheme FIRST — the schema already constrains it per transport, but as a host-side floor reject + // anything outside the http/ws family BEFORE the `allow_local_endpoint` relaxation, so an opt-in local endpoint + // can never wave through a `file:`/`javascript:`/etc. scheme (defense-in-depth, ADR-0053). + const scheme = parsed.protocol; + if (scheme !== 'http:' && scheme !== 'https:' && scheme !== 'ws:' && scheme !== 'wss:') { + throw new CliError( + 'invalid_invocation', + `MCP server '${serverId}': unsupported url scheme '${scheme.replace(':', '')}' (http/https/ws/wss only).`, + ); + } const host = parsed.hostname.replace(/^\[/, '').replace(/\]$/, ''); - const isSecure = parsed.protocol === 'https:' || parsed.protocol === 'wss:'; + const isSecure = scheme === 'https:' || scheme === 'wss:'; if (isPrivateOrLocalHost(host)) { if (!allowLocal) { throw new CliError( diff --git a/apps/cli/src/harness/mcp-stdio.e2e.test.ts b/apps/cli/src/harness/mcp-stdio.e2e.test.ts index 8990bf72..794bc399 100644 --- a/apps/cli/src/harness/mcp-stdio.e2e.test.ts +++ b/apps/cli/src/harness/mcp-stdio.e2e.test.ts @@ -35,47 +35,72 @@ const echoServer = (id = 'echo'): McpServerRef => ({ args: [FIXTURE], }); -/** Narrow the capability's `unknown` result to the Relavium tool-result shape for assertion. */ -const asResult = (value: unknown): McpToolResult => value as McpToolResult; +/** Narrow an optional to its value (vitest's `expect(...).toBeDefined()` asserts but does NOT narrow the type). */ +function defined(value: T | undefined, what: string): T { + if (value === undefined) throw new Error(`expected ${what} to be defined`); + return value; +} + +/** Verify the capability's `unknown` result really is the Relavium tool-result shape, narrowing it (no cast). */ +function assertToolResult(value: unknown): asserts value is McpToolResult { + if ( + typeof value !== 'object' || + value === null || + !('content' in value) || + !('isError' in value) || + !Array.isArray(value.content) || + typeof value.isError !== 'boolean' + ) { + throw new Error('expected an McpToolResult { content[]; isError }'); + } +} describe('inbound MCP — real stdio spawn (2.R Step 5)', () => { it('chat host: spawns the fixture, discovers + namespaces its tools, round-trips a real tools/call', async () => { - const client = await connectAgentMcp([echoServer()], { cwd: process.cwd() }); - expect(client).toBeDefined(); + const client = defined( + await connectAgentMcp([echoServer()], { cwd: process.cwd() }), + 'mcp client', + ); try { // Discovery: every fixture tool is namespaced `mcp_{server}_{tool}`, grouped under the server id. - expect([...(client!.toolIdsByServer.get('echo') ?? [])].sort()).toEqual([ + expect([...(client.toolIdsByServer.get('echo') ?? [])].sort()).toEqual([ 'mcp_echo_add', 'mcp_echo_echo', 'mcp_echo_whoami', ]); - expect(client!.toolDefs.map((d) => d.id).sort()).toEqual([ + expect(client.toolDefs.map((d) => d.id).sort()).toEqual([ 'mcp_echo_add', 'mcp_echo_echo', 'mcp_echo_whoami', ]); - expect(client!.skipped).toEqual([]); + expect(client.skipped).toEqual([]); // A real tools/call over the spawned child round-trips (echo returns its text verbatim). - const echoed = asResult( - await client!.capability.call({ server: 'echo', tool: 'echo', args: { text: 'pong' } }), - ); + const echoed = await client.capability.call({ + server: 'echo', + tool: 'echo', + args: { text: 'pong' }, + }); + assertToolResult(echoed); expect(echoed.isError).toBe(false); expect(echoed.content).toEqual([{ type: 'text', text: 'pong' }]); // The second tool proves multi-tool routing (args validated by the compiled inputSchema). - const sum = asResult( - await client!.capability.call({ server: 'echo', tool: 'add', args: { a: 2, b: 40 } }), - ); + const sum = await client.capability.call({ + server: 'echo', + tool: 'add', + args: { a: 2, b: 40 }, + }); + assertToolResult(sum); expect(sum.content).toEqual([{ type: 'text', text: '42' }]); } finally { - await client!.close(); // tears the stdio child down (idempotent) + await client.close(); // tears the stdio child down (idempotent) } }); it('run host: augments the declaring agent grant with the discovered ids and routes a real call', async () => { - // The absolute spawn command + fixture path are machine-specific, so they're injected (double-quoted — - // valid YAML flow scalars) rather than hard-coded; the parsed workflow stays immutable. + // The absolute spawn command + fixture path are machine-specific, so they're injected via JSON.stringify + // (a valid YAML flow scalar that escapes Windows backslashes correctly); the parsed workflow stays immutable. const def = parseWorkflow( [ "schema_version: '1.0'", @@ -88,7 +113,7 @@ describe('inbound MCP — real stdio spawn (2.R Step 5)', () => { ' system_prompt: go', ' tools: [read_file]', ' mcp_servers:', - ` - { id: echo, transport: stdio, command: "${process.execPath}", args: ["${FIXTURE}"] }`, + ` - { id: echo, transport: stdio, command: ${JSON.stringify(process.execPath)}, args: [${JSON.stringify(FIXTURE)}] }`, ' nodes:', ' - { id: s, type: input }', ' - { id: a, type: agent, agent_ref: scanner, prompt_template: go }', @@ -100,19 +125,22 @@ describe('inbound MCP — real stdio spawn (2.R Step 5)', () => { ].join('\n'), ); - const runtime = await connectWorkflowMcp(def, { cwd: process.cwd() }); - expect(runtime).toBeDefined(); + const runtime = defined( + await connectWorkflowMcp(def, { cwd: process.cwd() }), + 'workflow runtime', + ); try { - expect([...(runtime!.client.toolIdsByServer.get('echo') ?? [])].sort()).toEqual([ + expect([...(runtime.client.toolIdsByServer.get('echo') ?? [])].sort()).toEqual([ 'mcp_echo_add', 'mcp_echo_echo', 'mcp_echo_whoami', ]); // The run path unions the agent's declared grant with ITS server's discovered tool ids. - const augmented = (runtime!.workflow.workflow.agents ?? []).find( + const scanner = (runtime.workflow.workflow.agents ?? []).find( (e): e is Agent => 'id' in e && e.id === 'scanner', - )!; + ); + const augmented = defined(scanner, 'augmented scanner agent'); expect([...(augmented.tools ?? [])].sort()).toEqual([ 'mcp_echo_add', 'mcp_echo_echo', @@ -120,16 +148,15 @@ describe('inbound MCP — real stdio spawn (2.R Step 5)', () => { 'read_file', ]); - const echoed = asResult( - await runtime!.client.capability.call({ - server: 'echo', - tool: 'echo', - args: { text: 'wired' }, - }), - ); + const echoed = await runtime.client.capability.call({ + server: 'echo', + tool: 'echo', + args: { text: 'wired' }, + }); + assertToolResult(echoed); expect(echoed.content).toEqual([{ type: 'text', text: 'wired' }]); } finally { - await runtime!.client.close(); + await runtime.client.close(); } }); @@ -144,23 +171,24 @@ describe('inbound MCP — real stdio spawn (2.R Step 5)', () => { args: [FIXTURE], env: { MCP_FIXTURE_TOKEN: '{{secrets.t}}' }, }; - const client = await connectAgentMcp([server], { - cwd: process.cwd(), - resolveSecret: (name) => (name === 't' ? 'SENTINEL-abc123' : ''), - }); - expect(client).toBeDefined(); + const client = defined( + await connectAgentMcp([server], { + cwd: process.cwd(), + resolveSecret: (name) => (name === 't' ? 'SENTINEL-abc123' : ''), + }), + 'mcp client', + ); try { // The env injection must not perturb discovery: `whoami` is admitted as a callable LLM tool (not skipped), // so the round-trip below proves custody, not merely the direct-dispatch path bypassing the grant. - expect(client!.skipped).toEqual([]); - expect(client!.toolDefs.map((d) => d.id)).toContain('mcp_echo_whoami'); + expect(client.skipped).toEqual([]); + expect(client.toolDefs.map((d) => d.id)).toContain('mcp_echo_whoami'); - const who = asResult( - await client!.capability.call({ server: 'echo', tool: 'whoami', args: {} }), - ); + const who = await client.capability.call({ server: 'echo', tool: 'whoami', args: {} }); + assertToolResult(who); expect(who.content).toEqual([{ type: 'text', text: 'SENTINEL-abc123' }]); } finally { - await client!.close(); + await client.close(); } }); }); diff --git a/apps/cli/src/secrets/mcp-secret.test.ts b/apps/cli/src/secrets/mcp-secret.test.ts index eaabf11f..877c971c 100644 --- a/apps/cli/src/secrets/mcp-secret.test.ts +++ b/apps/cli/src/secrets/mcp-secret.test.ts @@ -59,10 +59,10 @@ describe('createMcpSecretResolver', () => { resolve('missing'); expect.unreachable('a missing secret must throw'); } catch (err) { - expect(isCliError(err) && err.code).toBe('invalid_invocation'); - const msg = (err as Error).message; - expect(msg).toContain('mcp-secret:missing'); // names the keychain account - expect(msg).toContain('RELAVIUM_MCP_MISSING'); // and the env var + if (!isCliError(err)) throw err; // narrow to CliError (no cast) + expect(err.code).toBe('invalid_invocation'); + expect(err.message).toContain('mcp-secret:missing'); // names the keychain account + expect(err.message).toContain('RELAVIUM_MCP_MISSING'); // and the env var } }); diff --git a/docs/reference/contracts/config-spec.md b/docs/reference/contracts/config-spec.md index 7781dad0..4deeb6b3 100644 --- a/docs/reference/contracts/config-spec.md +++ b/docs/reference/contracts/config-spec.md @@ -75,7 +75,7 @@ command = "npx -y @modelcontextprotocol/server-filesystem" args = ["--root", "~/projects"] autostart = true # accepted, reserved for a future always-on pool — NOT acted on in 2.R (a server connects on demand) # url = "https://host/mcp" # for transport = http (Streamable HTTP); a `websocket` server uses wss:// -# env = { TOKEN = "..." } # stdio only — injected into the spawned child; rejected on a network transport (header-auth is a follow-up) +# env = { TOKEN = "{{secrets.github_token}}" } # stdio only — resolved from the isolated mcp-secret:* keychain, injected into the spawned child; rejected on a network transport (header-auth is a follow-up) # allow_local_endpoint = true # opt into a private/loopback url (network transports only, ADR-0053 §3) ``` diff --git a/docs/standards/security-review.md b/docs/standards/security-review.md index 065de8fb..d0b4903a 100644 --- a/docs/standards/security-review.md +++ b/docs/standards/security-review.md @@ -131,7 +131,11 @@ SSRF range-primitive — never a second hand-rolled parser. The same validation private/loopback/link-local/metadata ranges** — `127.0.0.0/8`, `::1`, `10/8`, `172.16/12`, `192.168/16`, `100.64/10` (CGNAT), `169.254/16` incl. the cloud metadata IP `169.254.169.254`, unless the user has explicitly opted into a local endpoint) applies to **all four** — including the media `url` -carrier fetched by `fetchMediaBytes`: +carrier fetched by `fetchMediaBytes`. NOTE: the shared primitive is the **range block**; the stronger +**connect-by-validated-IP + per-redirect revalidation** is wired for the media carrier (and is construction-time +for the provider `baseURL` / `http_request`), but the **MCP** path is on the **pre-connect host floor** only +until its dialer lands (see the MCP bullet) — so a DNS-rebind / redirect-to-private window is MCP-specific, not +a property of all four. - **Provider `baseURL`.** DeepSeek (and any OpenAI-compatible provider) is reached via a user-supplied `baseURL`. Never let an agent-config URL cause the engine to call an @@ -145,22 +149,19 @@ carrier fetched by `fetchMediaBytes`: binding rule — not a second home. See [ADR-0029](../decisions/0029-tool-policy-hardening.md) and [built-in-tools.md](../reference/shared-core/built-in-tools.md). -- **MCP server URLs.** *(Security tightening — ADR-0029(d); transport vocab reconciled by ADR-0052 §5; network egress per ADR-0053.)* An MCP - `http` (Streamable HTTP) / `websocket` server URL (`sse` is a deprecated alias of `http`) - is a second egress path, so leaving it scheme-checked-only while hardening `http_request` - would be strictly worse. MCP server URLs run the **same** SSRF range-primitive (no second - parser): private/loopback/link-local/metadata hosts are rejected and a remote host must use - `https`/`wss`, **unless** the per-server `allow_local_endpoint` opt-in is set — which relaxes - the block AND permits plaintext for exactly the **authored `host:port`** (and never relaxes the - no-embedded-credentials check). 2.R ships this as a **pre-connect host floor** (the SDK opens its - own socket): a hostname that DNS-resolves to a private IP, or a redirect to one, is the residual - window the connect-by-validated-IP dialer closes — tracked in - [deferred-tasks.md](../roadmap/deferred-tasks.md) ([ADR-0053](../decisions/0053-mcp-network-transport-egress-security.md) §2/§3). - (A `stdio` server takes no URL; its secrets are injected into the spawned child's `env` — a network - transport (`http`, its deprecated `sse` alias, or `websocket`) has no process, so `env` is rejected - there, header-auth being a follow-up.) See - [mcp-integration.md](../reference/shared-core/mcp-integration.md) for the MCP contract - and [ADR-0029](../decisions/0029-tool-policy-hardening.md) for the rationale. +- **MCP server URLs.** *(Security tightening — ADR-0029(d); network egress per ADR-0053.)* A network MCP + (`http`/`websocket`) server URL is a second egress path, so leaving it scheme-checked-only while hardening + `http_request` would be strictly worse. MCP server URLs run the **same** SSRF range-primitive (no second + parser): private/loopback/link-local/metadata hosts are rejected and a remote host must use `https`/`wss`, + unless the per-server `allow_local_endpoint` opt-in is set (which never relaxes the no-credentials check). + **MCP is a temporary exception to the full egress guarantee:** 2.R ships only the **pre-connect host floor** + (the SDK opens its own socket), so a hostname that DNS-resolves to a private IP — or a redirect to one — + remains a residual window until the connect-by-validated-IP dialer (resolve → validate → connect-by-IP → + re-validate per redirect) lands; tracked in [deferred-tasks.md](../roadmap/deferred-tasks.md) + ([ADR-0053](../decisions/0053-mcp-network-transport-egress-security.md) §2/§3). The transport vocabulary, + the `allow_local_endpoint` host:port scope, and the `env`-injection scoping are specified in their canonical + home — [mcp-integration.md](../reference/shared-core/mcp-integration.md); see + [ADR-0029](../decisions/0029-tool-policy-hardening.md) for the rationale. - **Media `url` carrier (multimodal — [ADR-0031](../decisions/0031-llm-seam-shape-amendment-multimodal-io.md) A7 / [ADR-0043](../decisions/0043-media-egress-failover-rematerialization-ssrf.md)) — a fourth path, now wired host-side.** A media `url` source (a provider-returned output URL re-hosted to a handle, or a user-supplied input URL) is fetched through the **host** `fetchMedia` port — `@relavium/db`'s **`fetchMediaBytes`**, the one vetted diff --git a/packages/mcp/test-fixtures/echo-mcp-server.mjs b/packages/mcp/test-fixtures/echo-mcp-server.mjs index cd2097c1..b01c64af 100644 --- a/packages/mcp/test-fixtures/echo-mcp-server.mjs +++ b/packages/mcp/test-fixtures/echo-mcp-server.mjs @@ -13,6 +13,8 @@ // actually reaches the spawned child process (the last hop of the secret-custody chain). // // Fail-fast: any startup error exits non-zero so the spawning host's fail-loud connect surfaces it. +import process from 'node:process'; + import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; diff --git a/packages/shared/src/agent.test.ts b/packages/shared/src/agent.test.ts index 8a2b4ba5..73b9c49d 100644 --- a/packages/shared/src/agent.test.ts +++ b/packages/shared/src/agent.test.ts @@ -54,6 +54,26 @@ describe('AgentSchema', () => { ).toBe(false); }); + it('rejects duplicate `ref` names within an agent (the dedup keys on `ref ?? id`)', () => { + expect( + AgentSchema.safeParse({ + ...summarizer, + mcp_servers: [{ ref: 'shared-fs' }, { ref: 'shared-fs', tools_allowlist: ['read'] }], + }).success, + ).toBe(false); + }); + + it('rejects a `ref` whose name collides with an inline server `id` (same namespace segment)', () => { + // `ref ?? id` makes a `{ ref: fs }` and an inline `{ id: fs }` share one namespace key — a collision the + // schema rejects, so the host never resolves two distinct servers onto one `mcp_fs_*` namespace. + expect( + AgentSchema.safeParse({ + ...summarizer, + mcp_servers: [{ ref: 'fs' }, { id: 'fs', transport: 'stdio', command: 'npx' }], + }).success, + ).toBe(false); + }); + it('accepts a minimal agent with only the required fields', () => { expect( AgentSchema.safeParse({ diff --git a/packages/shared/src/agent.ts b/packages/shared/src/agent.ts index 77197e44..4031983e 100644 --- a/packages/shared/src/agent.ts +++ b/packages/shared/src/agent.ts @@ -64,9 +64,6 @@ export type Retry = z.infer; */ export const McpTransportSchema = z.enum(['stdio', 'http', 'websocket', 'sse']); -/** Allowed URL schemes for a network MCP server — never file:/javascript:/etc. */ -const SAFE_MCP_URL = /^(https?|wss?):\/\//i; - /** The inline connection fields a by-name `ref` must NOT carry (the registration provides them). */ const INLINE_CONNECTION_FIELDS = [ 'id', @@ -78,6 +75,97 @@ const INLINE_CONNECTION_FIELDS = [ 'allow_local_endpoint', ] as const; +/** The authored shape `McpServerRefSchema.superRefine` validates (every field optional pre-refinement). */ +interface McpServerRefDraft { + id?: string | undefined; + transport?: 'stdio' | 'http' | 'websocket' | 'sse' | undefined; + command?: string | undefined; + args?: readonly string[] | undefined; + env?: Record | undefined; + url?: string | undefined; + allow_local_endpoint?: boolean | undefined; + ref?: string | undefined; + tools_allowlist?: readonly string[] | undefined; +} + +/** The by-name `ref` form: identity + connection come from the registration, so NO inline field may accompany it. */ +function validateRefForm(ref: McpServerRefDraft, ctx: z.RefinementCtx): void { + for (const field of INLINE_CONNECTION_FIELDS) { + if (ref[field] !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `'${field}' is not allowed with 'ref' — the [[mcp_servers]] registration provides it`, + path: [field], + }); + } + } +} + +/** Inline `stdio`: needs a `command`, and rejects the network-only `url` / `allow_local_endpoint` (secure-by-default). */ +function validateStdioFields(ref: McpServerRefDraft, ctx: z.RefinementCtx): void { + if (!ref.command) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "command is required for the 'stdio' transport", + path: ['command'], + }); + } + if (ref.url !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "url is not used by the 'stdio' transport", + path: ['url'], + }); + } + if (ref.allow_local_endpoint !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "allow_local_endpoint is not used by the 'stdio' transport (network transports only)", + path: ['allow_local_endpoint'], + }); + } +} + +/** Inline network (`http`/`sse`/`websocket`): needs a `url` (scheme-checked, credential-free), and rejects `env` + * (no child process to inject into — 2.R wires `env` ONLY into a stdio spawn; network header-auth is a follow-up). */ +function validateNetworkFields(ref: McpServerRefDraft, ctx: z.RefinementCtx): void { + if (!ref.url) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `url is required for the '${ref.transport ?? '?'}' transport`, + path: ['url'], + }); + } + if (ref.env !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'env is not used by a network transport — it is injected only into a stdio server process (network header-auth is a follow-up)', + path: ['env'], + }); + } + if (ref.url !== undefined) { + // Per-transport scheme: `websocket` is WS(S); `http`/`sse` are HTTP(S). Also blocks file:/javascript:/etc. + const schemeOk = + ref.transport === 'websocket' ? /^wss?:\/\//i.test(ref.url) : /^https?:\/\//i.test(ref.url); + if (!schemeOk) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `url scheme is invalid for the '${ref.transport ?? '?'}' transport (http/sse → http(s), websocket → ws(s))`, + path: ['url'], + }); + } + if (URL_HAS_CREDENTIALS.test(ref.url)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'url must not embed credentials (user:pass@…) — use env/keychain auth', + path: ['url'], + }); + } + } +} + /** * A reference to an MCP server an agent consumes (`McpServerRef`) — one of two mutually-exclusive forms * ([ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §5): @@ -107,17 +195,9 @@ export const McpServerRefSchema = z }) .strict() .superRefine((ref, ctx) => { - // The by-name `ref` form: identity + connection come from the registration; inline fields are forbidden. + // By-name `ref` vs inline are mutually exclusive; each form has its own validator (kept small for clarity). if (ref.ref !== undefined) { - for (const field of INLINE_CONNECTION_FIELDS) { - if (ref[field] !== undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `'${field}' is not allowed with 'ref' — the [[mcp_servers]] registration provides it`, - path: [field], - }); - } - } + validateRefForm(ref, ctx); return; } // The inline form: `id` + `transport` are required (a `ref` is the only way to omit them). @@ -135,81 +215,10 @@ export const McpServerRefSchema = z message: "an inline server requires 'transport' (or use 'ref')", path: ['transport'], }); - return; // the per-transport checks below need a transport - } - if (ref.transport === 'stdio') { - if (!ref.command) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "command is required for the 'stdio' transport", - path: ['command'], - }); - } - // A stdio server has no `url` — reject a stray one (a mis-declared server fails at parse, secure-by-default). - if (ref.url !== undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "url is not used by the 'stdio' transport", - path: ['url'], - }); - } - // `allow_local_endpoint` is a network-only SSRF opt-in — reject it on stdio (mirrors the `url` guard). - if (ref.allow_local_endpoint !== undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - "allow_local_endpoint is not used by the 'stdio' transport (network transports only)", - path: ['allow_local_endpoint'], - }); - } - } - if (ref.transport !== 'stdio') { - if (!ref.url) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `url is required for the '${ref.transport}' transport`, - path: ['url'], - }); - } - // A network transport spawns no child process, so `env` (and its `{{secrets.*}}`) has nowhere to be - // injected — 2.R wires `env` ONLY into the stdio spawn. Reject it loud rather than silently dropping what - // is almost always an auth secret (the fail-closed posture, ADR-0052 §6); network header-auth is a tracked - // follow-up (deferred-tasks.md). Mirrors the stdio `url` / `allow_local_endpoint` guards above. - if (ref.env !== undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - 'env is not used by a network transport — it is injected only into a stdio server process (network header-auth is a follow-up)', - path: ['env'], - }); - } - } - if (ref.url !== undefined && ref.transport !== 'stdio') { - // Per-transport scheme: `http`/`sse` are HTTP(S), `websocket` is WS(S). A stdio `url` is already rejected - // outright above (its scheme is irrelevant), so this scheme/credentials pass is network-only — otherwise a - // stray stdio url would double-issue ("url not used by stdio" AND "scheme invalid"). - // This also blocks file:/javascript:/etc. as a side effect. - let schemeOk: boolean; - if (ref.transport === 'websocket') schemeOk = /^wss?:\/\//i.test(ref.url); - else if (ref.transport === 'http' || ref.transport === 'sse') - schemeOk = /^https?:\/\//i.test(ref.url); - else schemeOk = SAFE_MCP_URL.test(ref.url); - if (!schemeOk) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `url scheme is invalid for the '${ref.transport}' transport (http/sse → http(s), websocket → ws(s))`, - path: ['url'], - }); - } - // Secret hygiene: no credentials embedded in a git-committed url. - if (URL_HAS_CREDENTIALS.test(ref.url)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'url must not embed credentials (user:pass@…) — use env/keychain auth', - path: ['url'], - }); - } + return; // the per-transport checks need a transport } + if (ref.transport === 'stdio') validateStdioFields(ref, ctx); + else validateNetworkFields(ref, ctx); }); export type McpServerRef = z.infer; diff --git a/packages/shared/src/config.test.ts b/packages/shared/src/config.test.ts index a6b1b1a5..458f5d4f 100644 --- a/packages/shared/src/config.test.ts +++ b/packages/shared/src/config.test.ts @@ -118,16 +118,19 @@ describe('config schemas', () => { ).toBe(true); }); - it('rejects `env` on a network MCP registration (env is injected only into a stdio child — fail-closed)', () => { - // Mirrors the inline `McpServerRefSchema` guard: a committed network registration can't carry a dead `env` - // whose `{{secrets.*}}` would be silently discarded. - expect( - GlobalConfigSchema.safeParse({ - mcp_servers: [ - { name: 'docs', transport: 'http', url: 'https://docs.example/mcp', env: { TOKEN: 'x' } }, - ], - }).success, - ).toBe(false); + it('rejects `env` on EVERY network MCP registration (env is injected only into a stdio child — fail-closed)', () => { + // Mirrors the inline `McpServerRefSchema` guard for http AND websocket: a committed network registration + // can't carry a dead `env` whose `{{secrets.*}}` would be silently discarded. + for (const net of [ + { transport: 'http', url: 'https://docs.example/mcp' }, + { transport: 'websocket', url: 'wss://docs.example/ws' }, + ] as const) { + expect( + GlobalConfigSchema.safeParse({ + mcp_servers: [{ name: 'docs', ...net, env: { TOKEN: 'x' } }], + }).success, + ).toBe(false); + } // env on a stdio registration is still accepted. expect( GlobalConfigSchema.safeParse({ @@ -136,6 +139,16 @@ describe('config schemas', () => { ).toBe(true); }); + it('rejects a stray `url` on a stdio MCP registration (network-only — matches the inline ref schema)', () => { + // The inline `McpServerRefSchema` rejects a url on stdio; the registration schema must agree so a committed + // config can't carry a mis-declared stdio server (its scheme is irrelevant — its mere presence is the error). + expect( + GlobalConfigSchema.safeParse({ + mcp_servers: [{ name: 'fs', transport: 'stdio', command: 'npx', url: 'https://host/mcp' }], + }).success, + ).toBe(false); + }); + it('rejects `allow_local_endpoint` on a stdio MCP registration (network-only — matches the inline ref schema)', () => { // The inline `McpServerRefSchema` (agent.ts) rejects the network-only flag on stdio; the registration // schema must agree, so a committed config can't carry a dead opt-in that an equivalent inline entry refuses. diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index 9e5c1672..8aa8e90e 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -18,6 +18,85 @@ export const FsScopeSchema = z.enum(FS_SCOPE_TIERS); const SAFE_HTTP_URL = /^https?:\/\//i; const SAFE_WS_URL = /^wss?:\/\//i; +/** The authored shape `McpServerRegistrationSchema.superRefine` validates (connection fields optional pre-refine). */ +interface McpRegistrationDraft { + name: string; + transport: 'stdio' | 'http' | 'websocket'; + command?: string | undefined; + args?: readonly string[] | undefined; + autostart?: boolean | undefined; + url?: string | undefined; + env?: Record | undefined; + allow_local_endpoint?: boolean | undefined; +} + +/** `stdio` registration: needs a `command`; rejects the network-only `url` / `allow_local_endpoint` so a committed + * registration's contract matches the inline `McpServerRefSchema` (a dead flag would also skew `serverFingerprint`). */ +function validateStdioRegistration(server: McpRegistrationDraft, ctx: z.RefinementCtx): void { + if (!server.command) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "command is required for the 'stdio' transport", + path: ['command'], + }); + } + if (server.url !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "url is not used by the 'stdio' transport", + path: ['url'], + }); + } + if (server.allow_local_endpoint !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "allow_local_endpoint is not used by the 'stdio' transport (network transports only)", + path: ['allow_local_endpoint'], + }); + } +} + +/** Network registration (`http`/`websocket`): needs a scheme-checked, credential-free `url`; rejects `env` (no child + * process to inject into — 2.R wires `env` ONLY into a stdio spawn; network header-auth is a tracked follow-up). */ +function validateNetworkRegistration(server: McpRegistrationDraft, ctx: z.RefinementCtx): void { + if (!server.url) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `url is required for the '${server.transport}' transport`, + path: ['url'], + }); + } + if (server.env !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'env is not used by a network transport — it is injected only into a stdio server process (network header-auth is a follow-up)', + path: ['env'], + }); + } + if (server.url !== undefined) { + const schemeOk = + server.transport === 'websocket' + ? SAFE_WS_URL.test(server.url) + : SAFE_HTTP_URL.test(server.url); + if (!schemeOk) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'url scheme is invalid (http → http(s), websocket → ws(s))', + path: ['url'], + }); + } + if (URL_HAS_CREDENTIALS.test(server.url)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'url must not embed credentials (user:pass@…) — use env/keychain auth', + path: ['url'], + }); + } + } +} + /** * An MCP server registration (`[[mcp_servers]]`). The transport dictates the required connection field: * `stdio` needs a `command`; `http` (Streamable HTTP) / `websocket` need a `url`. Reconciled with the agent @@ -39,69 +118,8 @@ export const McpServerRegistrationSchema = z // .strict(): a typo in a committed MCP key (e.g. `autostrat`) fails loudly — strict config per ADR-0033 (which amends ADR-0023's config carve-out). .strict() .superRefine((server, ctx) => { - if (server.transport === 'stdio' && !server.command) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "command is required for the 'stdio' transport", - path: ['command'], - }); - } - // `allow_local_endpoint` is a network-only SSRF opt-in — reject it on stdio so a registration's - // contract matches the inline `McpServerRefSchema` (agent.ts), which rejects the same field on stdio. - // Without this, a committed `[[mcp_servers]]` stdio entry could carry a dead flag that silently passes - // here yet fails an equivalent inline declaration — and, because it is part of `serverFingerprint`, it - // could skew cross-agent dedup. Network transports only (ADR-0053 §3). - if (server.transport === 'stdio' && server.allow_local_endpoint !== undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - "allow_local_endpoint is not used by the 'stdio' transport (network transports only)", - path: ['allow_local_endpoint'], - }); - } - if (server.transport !== 'stdio') { - if (!server.url) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `url is required for the '${server.transport}' transport`, - path: ['url'], - }); - } - // A network registration spawns no child process, so `env` (and its `{{secrets.*}}`) has nowhere to be - // injected — 2.R wires `env` ONLY into the stdio spawn. Reject it loud rather than silently dropping an - // auth secret (fail-closed, ADR-0052 §6); network header-auth is a tracked follow-up. Mirrors the inline - // `McpServerRefSchema` guard so a committed registration can't carry a dead network `env`. - if (server.env !== undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - 'env is not used by a network transport — it is injected only into a stdio server process (network header-auth is a follow-up)', - path: ['env'], - }); - } - } - if (server.url !== undefined) { - // SSRF guard: a registered url must use the transport's scheme — reject file:, javascript:, etc. - const schemeOk = - server.transport === 'websocket' - ? SAFE_WS_URL.test(server.url) - : SAFE_HTTP_URL.test(server.url); - if (!schemeOk) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'url scheme is invalid (http → http(s), websocket → ws(s))', - path: ['url'], - }); - } - // Secret hygiene: no credentials embedded in a git-committed url. - if (URL_HAS_CREDENTIALS.test(server.url)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'url must not embed credentials (user:pass@…) — use env/keychain auth', - path: ['url'], - }); - } - } + if (server.transport === 'stdio') validateStdioRegistration(server, ctx); + else validateNetworkRegistration(server, ctx); }); export type McpServerRegistration = z.infer; From 4901e3b342b10249f806364af5af4476e559945e Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 27 Jun 2026 23:58:18 +0300 Subject: [PATCH 21/24] =?UTF-8?q?fix(shared,mcp,cli,docs):=202.R=20end-to-?= =?UTF-8?q?end=20review=20=E2=80=94=20close=20SSRF=20IPv6=20bypass,=20sche?= =?UTF-8?q?ma/teardown=20gaps,=20broaden=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the 23 adversarially-confirmed findings from the comprehensive 8-dimension end-to-end review of the whole 2.R feature (PR #56 foundation + PR #57). Verified each against current code; minimal fixes. MEDIUM: - SSRF (shared primitive, affects EVERY egress caller): isPrivateIpv6Groups missed the deprecated IPv4-compatible IPv6 form `::a.b.c.d` (g[5]≠0xffff) — `::127.0.0.1` / `::169.254.169.254` (metadata) tunneled a private IPv4 past the block. Added the branch + BLOCKED/ALLOWED test cases (verified empirically). - Schema symmetry: both McpServerRefSchema and McpServerRegistrationSchema now reject the stdio-only fields `command`/`args` on a network transport (already rejected `env`/`url`/`allow_local`) — strict + keeps serverFingerprint clean. - chat-ink: the second-SIGINT hard `process.exit` bypassed runReplLoop's finally, orphaning spawned stdio children — it now runs a bounded best-effort MCP teardown (new ChatDriveContext.onForceExit, wired from built.closeMcp) before exiting. - Tests + docs: a routed-dispatch test for an `isError:true` (recoverable) MCP result; the dangling shared-core-engine.md MCP-lifecycle section now exists. LOW: - manager: connect servers CONCURRENTLY (startup bounded by the slowest single server, not the sum) with deterministic declaration-order tool-def assembly + unchanged fail-loud teardown. - schema-compiler: `const`+`enum` now INTERSECT (a contradictory pair accepts nothing) instead of const-wins. - empty `tools_allowlist: []` is rejected (omit to admit all — an empty list silently granted zero tools). - `agent run --fixture` is now fully offline (injects an empty MCP client so inline stdio servers never spawn). - connectWorkflowMcp's defensive try/catch is documented as defensive-only. - mcp-secret env-var normalization lossiness documented (keychain is the lossless source). - Coverage: packages/mcp now carries the enforced ≥90% line+branch floor (at 93.7/95.5). - New tests: network success path via the SDK InMemoryTransport pair; multi-agent cross-agent grant isolation e2e; real-AbortSignal dispatch forwarding; hostile server-segment sanitization; const+enum; empty-allowlist; network stray-field rejects. - Docs honesty: index.ts shipped-state; sse is inline-agent-only (not a registration transport); agent-yaml-spec gains an McpServerRef section; mcp_call node qualification. Refs: ADR-0052, ADR-0053, ADR-0029, ADR-0006 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/commands/agent-run.ts | 6 ++ apps/cli/src/commands/chat.ts | 10 +++ apps/cli/src/commands/run.test.ts | 35 ++++++++++ apps/cli/src/engine/mcp-servers.test.ts | 8 ++- apps/cli/src/engine/mcp-servers.ts | 7 +- apps/cli/src/harness/mcp-stdio.e2e.test.ts | 68 +++++++++++++++++++ apps/cli/src/render/tui/chat-ink.tsx | 22 ++++-- docs/architecture/shared-core-engine.md | 27 ++++++++ docs/reference/contracts/agent-yaml-spec.md | 9 +++ docs/reference/contracts/config-spec.md | 7 +- .../reference/desktop/keychain-and-secrets.md | 2 +- docs/reference/shared-core/mcp-integration.md | 6 +- docs/standards/testing.md | 10 +-- packages/mcp/src/index.ts | 14 ++-- packages/mcp/src/manager.ts | 51 +++++++++----- packages/mcp/src/network-adapters.test.ts | 40 ++++++++++- packages/mcp/src/schema-compiler.test.ts | 10 +++ packages/mcp/src/schema-compiler.ts | 13 +++- packages/mcp/src/tool-mapping.test.ts | 22 +++++- packages/shared/src/agent.test.ts | 41 +++++++++++ packages/shared/src/agent.ts | 27 +++++--- packages/shared/src/config.test.ts | 13 ++++ packages/shared/src/config.ts | 17 +++-- packages/shared/src/content.test.ts | 5 ++ packages/shared/src/content.ts | 15 ++-- vitest.config.ts | 1 + 26 files changed, 418 insertions(+), 68 deletions(-) diff --git a/apps/cli/src/commands/agent-run.ts b/apps/cli/src/commands/agent-run.ts index 7b856289..eb36aaa5 100644 --- a/apps/cli/src/commands/agent-run.ts +++ b/apps/cli/src/commands/agent-run.ts @@ -3,6 +3,8 @@ import { StringDecoder } from 'node:string_decoder'; import type { SessionStreamHandleEvent } from '@relavium/core'; +import { startMcpClient } from '@relavium/mcp'; + import { cassetteResolver, loadCassette } from '../chat/fixture.js'; import { buildChatSession } from '../chat/session-host.js'; import { loadResolvedConfig } from '../config/load.js'; @@ -102,6 +104,10 @@ export async function agentRunCommand( ? createMcpSecretResolver(deps.io.env) : (deps.mcpSecretResolver ?? createMcpSecretResolver(deps.io.env)), mcpRegistrations: offline ? [] : config.mcpServers, + // FULLY offline in `--fixture` mode: even if the agent declares inline stdio `mcp_servers`, never spawn a + // real child — inject an empty client (the configs are still built in-memory + env-only, but `open()` is + // never called). The cassette already carries any recorded tool results, so the replay needs no live MCP. + ...(offline ? { startMcpClient: () => startMcpClient([]) } : {}), }); // Render the live stream (NDJSON under --json, else the plain token/tool printer) and capture the turn // outcome — a classified turn failure completes with `session:turn_completed.error`, mapping to exit 1. diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index 24ac1af3..55c63d8c 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -91,6 +91,13 @@ export interface ChatDriveContext { * event (the command's own teardown fires the terminal only AFTER the driver has unsubscribed). Idempotent. */ readonly finalize?: () => void; + /** + * Best-effort teardown to run on a driver's HARD-exit path (the ink driver's second-SIGINT `process.exit`, + * which bypasses the command's `runReplLoop` finally). It tears the live MCP connections down so a forced quit + * never orphans a spawned stdio child. The command wires it to `built.closeMcp`; a driver awaits it (bounded) + * before `process.exit`. Absent ⇒ nothing to force-close. + */ + readonly onForceExit?: () => Promise; } export type ChatDriver = (ctx: ChatDriveContext) => Promise; @@ -409,6 +416,9 @@ async function runReplLoop(wiring: ReplWiring, deps: ChatReplDeps): Promise { expect(closed).toBe(1); // the connection was torn down at the run terminal }); + it('routes an MCP tool RESULT with isError:true through dispatch → engine as a RECOVERABLE error (run still completes)', async () => { + // The result contract's recoverable-error arm, end-to-end through the real manager + engine: a server tool that + // returns `{ isError: true }` is a tool-LEVEL (recoverable) error — the agent receives it and replies, the run + // does NOT fail. (The engine's tool_result event reports transport success; the isError rides in the content.) + const path = writeWorkflow('mcp-toolerror.relavium.yaml', MCP_WF); + const { io } = captureIo(); + let calls = 0; + const conn: McpConnection = { + listTools: () => Promise.resolve([{ name: 'read', inputSchema: { type: 'object' } }]), + callTool: () => { + calls += 1; + return Promise.resolve({ + content: [{ type: 'text', text: 'the server tool failed internally' }], + isError: true, + }); + }, + close: () => Promise.resolve(), + }; + const code = await runCommand( + { workflow: path, input: [] }, + { + io, + global: globalOptions(), + providers: scriptedResolver([toolUseTurn('c1', 'mcp_fs_read'), textTurn('recovered')]), + buildEngine: (opts) => buildEngine({ ...opts, host: createInMemoryHost() }), + startMcpClient: () => + realStartMcpClient([ + { id: 'fs', toolsAllowlist: ['read'], open: () => Promise.resolve(conn) }, + ]), + }, + ); + expect(code).toBe(EXIT_CODES.success); // the recoverable isError did NOT fail the run + expect(calls).toBe(1); // the call routed to the connection and returned the isError result + }); + it('a failed MCP connect is a clean fail-loud exit-2 CliError (cause stripped) before the engine is built', async () => { const path = writeWorkflow('mcp-connect-fail.relavium.yaml', MCP_WF); const { io } = captureIo(); diff --git a/apps/cli/src/engine/mcp-servers.test.ts b/apps/cli/src/engine/mcp-servers.test.ts index 6ce83078..5ea4f9eb 100644 --- a/apps/cli/src/engine/mcp-servers.test.ts +++ b/apps/cli/src/engine/mcp-servers.test.ts @@ -781,7 +781,13 @@ describe('surfaceMcpSkipped', () => { // crafted tool returning ANSI/OSC control bytes must NOT write them raw to the operator's terminal. const { io, err } = captureIo(); surfaceMcpSkipped(io, [ - { server: 'fs', name: 'evil\x1b[2J\x1b]0;pwned\x07', reason: 'bad\x1b[31m schema\x1b[0m' }, + // The `server` segment is ALSO sanitized (the by-name `ref` form derives it from a free registration name, + // ADR-0052 §4/§5), so feed it control bytes too — all three segments must be scrubbed. + { + server: 'srv\x1b[2J\x1b]0;x\x07', + name: 'evil\x1b[2J\x1b]0;pwned\x07', + reason: 'bad\x1b[31m schema\x1b[0m', + }, ]); const written = err(); // eslint-disable-next-line no-control-regex -- asserting the ABSENCE of control bytes is the point diff --git a/apps/cli/src/engine/mcp-servers.ts b/apps/cli/src/engine/mcp-servers.ts index ede76f54..b7445c56 100644 --- a/apps/cli/src/engine/mcp-servers.ts +++ b/apps/cli/src/engine/mcp-servers.ts @@ -464,8 +464,11 @@ export async function connectWorkflowMcp( }; return { client, workflow }; } catch (err) { - // The connection is live but assembling the augmented workflow failed — tear it down so a spawned child / - // open socket never leaks past this throw (uniform all-or-nothing with the self-cleaning chat builders). + // DEFENSIVE: the augmentation above is pure today (map + spreads + a regex `sanitizeServerSegment`) and + // cannot throw — the genuinely-throwing assembly (resolveServerConfigs / the dedup) ran BEFORE the client + // was opened. This guard exists so that if a future throwing transform is added here, the live connection is + // torn down rather than leaked (uniform all-or-nothing with the self-cleaning chat builders), not because the + // current body throws. Do not assume `withWorkflowMcpGrant` can fail. await client.close(); throw err; } diff --git a/apps/cli/src/harness/mcp-stdio.e2e.test.ts b/apps/cli/src/harness/mcp-stdio.e2e.test.ts index 794bc399..f76dfda0 100644 --- a/apps/cli/src/harness/mcp-stdio.e2e.test.ts +++ b/apps/cli/src/harness/mcp-stdio.e2e.test.ts @@ -160,6 +160,74 @@ describe('inbound MCP — real stdio spawn (2.R Step 5)', () => { } }); + it('run host: TWO agents each declaring a distinct server get ONLY their own tools (cross-agent isolation)', async () => { + // The run path's distinctive property, proven against REAL spawns: agent `a` declares `echo-a`, agent `b` + // declares `echo-b` (both spawn the same fixture under distinct ids). Each agent's grant must contain ONLY + // its own server's namespaced ids — never the other's — so a second agent can't reach a server it didn't ask for. + const node = process.execPath; + const def = parseWorkflow( + [ + "schema_version: '1.0'", + 'workflow:', + ' id: wf', + ' agents:', + ' - id: a', + ' model: claude-sonnet-4-6', + ' provider: anthropic', + ' system_prompt: go', + ' mcp_servers:', + ` - { id: echo-a, transport: stdio, command: ${JSON.stringify(node)}, args: [${JSON.stringify(FIXTURE)}] }`, + ' - id: b', + ' model: claude-sonnet-4-6', + ' provider: anthropic', + ' system_prompt: go', + ' mcp_servers:', + ` - { id: echo-b, transport: stdio, command: ${JSON.stringify(node)}, args: [${JSON.stringify(FIXTURE)}] }`, + ' nodes:', + ' - { id: s, type: input }', + ' - { id: na, type: agent, agent_ref: a, prompt_template: go }', + ' - { id: nb, type: agent, agent_ref: b, prompt_template: go }', + ' - { id: o, type: output }', + ' edges:', + ' - { from: s, to: na }', + ' - { from: na, to: nb }', + ' - { from: nb, to: o }', + '', + ].join('\n'), + ); + + const runtime = defined( + await connectWorkflowMcp(def, { cwd: process.cwd() }), + 'workflow runtime', + ); + try { + const agents = runtime.workflow.workflow.agents ?? []; + const agentA = defined( + agents.find((e): e is Agent => 'id' in e && e.id === 'a'), + 'agent a', + ); + const agentB = defined( + agents.find((e): e is Agent => 'id' in e && e.id === 'b'), + 'agent b', + ); + // Each agent sees ONLY its own server's namespaced ids — the other's are absent (per-agent isolation). + expect([...(agentA.tools ?? [])].sort()).toEqual([ + 'mcp_echo-a_add', + 'mcp_echo-a_echo', + 'mcp_echo-a_whoami', + ]); + expect([...(agentB.tools ?? [])].sort()).toEqual([ + 'mcp_echo-b_add', + 'mcp_echo-b_echo', + 'mcp_echo-b_whoami', + ]); + expect((agentA.tools ?? []).some((t) => t.startsWith('mcp_echo-b_'))).toBe(false); + expect((agentB.tools ?? []).some((t) => t.startsWith('mcp_echo-a_'))).toBe(false); + } finally { + await runtime.client.close(); + } + }); + it('chat host: resolves {{secrets.*}} into the spawned child env (the last hop of the secret-custody chain)', async () => { // The secret custody chain end-to-end against a REAL process: a `{{secrets.t}}` env placeholder is resolved // by the injected resolver, the host injects it into the spawned child's env, and the child's `whoami` tool diff --git a/apps/cli/src/render/tui/chat-ink.tsx b/apps/cli/src/render/tui/chat-ink.tsx index 74ef3e8b..75ad811b 100644 --- a/apps/cli/src/render/tui/chat-ink.tsx +++ b/apps/cli/src/render/tui/chat-ink.tsx @@ -32,6 +32,8 @@ import type { TranscriptEntry } from './session-view-model.js'; */ const FRAME_MS = 80; +/** The bound on the best-effort MCP teardown a forced (double-SIGINT) quit waits for before hard-exiting. */ +const FORCE_TEARDOWN_MS = 2000; function TranscriptLine(props: Readonly<{ entry: TranscriptEntry; color: boolean }>): ReactElement { const { entry, color } = props; @@ -194,11 +196,23 @@ export function driveInk(ctx: ChatDriveContext): Promise { const onSigint = (): void => { if (cancelRequested) { // A second SIGINT while the cooperative /cancel is still draining (e.g. a provider ignoring the abort): - // unmount ink FIRST so the terminal is restored from raw mode, THEN force a clean exit 4. (Process death - // reclaims the unref'd frame interval, the subscription, and this listener — the terminal restore is the - // one piece of cleanup that must not be skipped on the hard-exit path.) + // unmount ink FIRST so the terminal is restored from raw mode, then tear the live MCP connections down + // (best-effort, BOUNDED — a forced quit must not orphan a spawned stdio child, but must also not hang on a + // teardown), THEN force a clean exit 4. (Process death reclaims the unref'd frame interval, the + // subscription, and this listener; the terminal restore + MCP teardown are the cleanup that must not be + // skipped on the hard-exit path — the command's runReplLoop finally never runs after process.exit.) instance?.unmount(); - process.exit(EXIT_CODES.chatEnded); + const forceExit = ctx.onForceExit; + if (forceExit === undefined) { + process.exit(EXIT_CODES.chatEnded); + } + const bounded = new Promise((resolve) => + setTimeout(resolve, FORCE_TEARDOWN_MS).unref(), + ); + void Promise.race([forceExit().catch(() => undefined), bounded]).finally(() => + process.exit(EXIT_CODES.chatEnded), + ); + return; } cancelRequested = true; void ctx.processLine('/cancel').then(() => resolveExit(), rejectExit); diff --git a/docs/architecture/shared-core-engine.md b/docs/architecture/shared-core-engine.md index 0a529f9d..080ef83f 100644 --- a/docs/architecture/shared-core-engine.md +++ b/docs/architecture/shared-core-engine.md @@ -128,6 +128,33 @@ nodes and joins them at aggregator/merge points. Each node type maps to a handle How a single run progresses node-by-node — including streaming and the human gate — is covered in [execution-model.md](execution-model.md). +## Inbound MCP connection lifecycle + +The engine consumes external MCP servers' tools, but it never owns the connection — the +**host** does (the CLI/VS Code Node process, or the desktop Rust backend), so `packages/core` +stays platform-pure and never imports the SDK or `node:child_process` +([ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §1). +The lifecycle, as shipped in 2.R: + +- **Connect (host, at session/run start).** The host resolves each declared server, then + connects them **concurrently** and **fail-loud** (a failed spawn or `tools/list` fails the + whole start — never a silent capability loss; a partial connect tears down everything + opened). It namespaces the discovered tools as `mcp_{server}_{tool}`, narrows by any + `tools_allowlist`, fails a post-namespace collision closed, and hands the engine the + assembled `ToolDef`s + an `McpCapability` it routes through. +- **Keep-alive (for the session/run duration).** Connections stay open; the engine routes + each tool call through `host.mcp.call`. There is **no cross-invocation pool or tool-list + cache yet** (each process re-runs discovery on connect) and the registration `autostart` + field is **reserved, not acted on** — a referenced server connects on demand. +- **Teardown (at the terminal).** The host closes the connections after the session/run's + sole terminal — idempotent, best-effort, and never allowed to mask the run outcome (the + CLI's force-quit path also tears them down so a spawned stdio child is never orphaned). + +The full contract (the `McpServerRef` shape, the SSRF floor, named secrets, the transport +vocabulary) lives in its canonical home, +[../reference/shared-core/mcp-integration.md](../reference/shared-core/mcp-integration.md); +this section is only the connection-lifecycle narrative that page points back to. + ## The orchestrator-as-node concept Relavium supports two complementary control styles in the *same* engine: diff --git a/docs/reference/contracts/agent-yaml-spec.md b/docs/reference/contracts/agent-yaml-spec.md index a38dd662..e942db42 100644 --- a/docs/reference/contracts/agent-yaml-spec.md +++ b/docs/reference/contracts/agent-yaml-spec.md @@ -86,6 +86,15 @@ fallback_chain: The resolution order **within one attempt** is: primary `model` → first `fallback_chain` entry (with its `max_attempts`) → next entry → … ; the node `retry` budget ([ADR-0040](../../decisions/0040-node-retry-budget-above-the-chain.md)) then re-runs this whole sequence, above the chain, on a retryable failure. The `model`/`provider` pair and the `fallback_chain` are resolved against Relavium's provider-agnostic `LLMProvider` seam: each entry selects a provider adapter and a canonical model id, and the fallback is executed by the `withFallback` runner that sits *outside* the adapters (the adapters stay dumb). Provider-key resolution and cross-provider tool-schema normalization are likewise handled by `@relavium/llm`. The immovable contract for all of this — the request/result/stream types, the normalization rules, and where `fallback_chain` `max_attempts` is enforced — is [../shared-core/llm-provider-seam.md](../shared-core/llm-provider-seam.md); the rationale and supported-model matrix are in [../../architecture/multi-llm-providers.md](../../architecture/multi-llm-providers.md). +## MCP servers (`mcp_servers`) + +Each entry of `mcp_servers` is an **`McpServerRef`** — one of two mutually-exclusive forms ([ADR-0052](../../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §5): + +- **Inline** — self-contained: `{ id, transport, … }`, where the transport (`stdio | http | websocket`, plus the deprecated `sse` alias of `http` for older servers) dictates the required connection field — `stdio` needs a `command` (with optional `args`/`env`); a network transport needs a `url` (and may set `allow_local_endpoint` to opt into a private/loopback endpoint). +- **By-name `ref`** — `{ ref: , tools_allowlist? }`: identity AND connection come from a `[[mcp_servers]]` registration ([config-spec.md](config-spec.md)); the inline connection fields are forbidden alongside `ref` (the registration provides them). + +Declaring a server implicitly grants that agent its (allowlist-narrowed) discovered tools, namespaced `mcp_{server}_{tool}`. Entries are unique per agent on `ref ?? id`. Declaring `mcp_servers` is a JSON-Schema-validated shape; the **full contract** (the field-by-field schema, the SSRF floor, named-secret resolution, transport reconciliation) lives in its canonical home, [../shared-core/mcp-integration.md](../shared-core/mcp-integration.md) — this section is only the agent-surface summary. + ## Example ```yaml diff --git a/docs/reference/contracts/config-spec.md b/docs/reference/contracts/config-spec.md index 4deeb6b3..907ab76e 100644 --- a/docs/reference/contracts/config-spec.md +++ b/docs/reference/contracts/config-spec.md @@ -81,8 +81,11 @@ autostart = true # accepted, reserved for a future always-on p A `transport = "http"` / `"websocket"` registration requires a `url` (`http(s)` for `http`, `ws(s)` for `websocket`); the url is SSRF-guarded (a private/loopback host is rejected unless `allow_local_endpoint` is set; -a remote host must be `https`/`wss`) and must not embed credentials. An agent consumes a registration with -`- ref: filesystem` (see [../shared-core/mcp-integration.md](../shared-core/mcp-integration.md)). +a remote host must be `https`/`wss`) and must not embed credentials. A registration's transport is +**`stdio | http | websocket`** — the deprecated `sse` alias is accepted only on an inline `agent.mcp_servers` +entry (for older servers), not here; register with `http` instead. The stdio-only fields (`command`/`args`/`env`) +are rejected on a network registration, and the network-only fields (`url`/`allow_local_endpoint`) on a stdio one. +An agent consumes a registration with `- ref: filesystem` (see [../shared-core/mcp-integration.md](../shared-core/mcp-integration.md)). ## `project.toml` / `workspace.toml` (project) — keys diff --git a/docs/reference/desktop/keychain-and-secrets.md b/docs/reference/desktop/keychain-and-secrets.md index 3e2c07bd..d5b3eadc 100644 --- a/docs/reference/desktop/keychain-and-secrets.md +++ b/docs/reference/desktop/keychain-and-secrets.md @@ -49,7 +49,7 @@ The built-in web-search tool stores its key the same way under `account = search **Inbound MCP named secrets** (2.R, [ADR-0052](../../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §6) live in a **separate, isolated namespace**: - `account` = `mcp-secret:` (e.g. `mcp-secret:github-token`) -- env-var fallback = `RELAVIUM_MCP_` (the name upper-cased, non-`[A-Z0-9]` → `_`), mirroring the provider `RELAVIUM__API_KEY` chain. +- env-var fallback = `RELAVIUM_MCP_` (the name upper-cased, non-`[A-Z0-9]` → `_`), mirroring the provider `RELAVIUM__API_KEY` chain. **The env-var mapping is lossy** — two secret names differing only in separators (`gh-api` vs `gh.api`) collapse to the same `RELAVIUM_MCP_GH_API`, so the env-var fallback can serve one name's value for the other. The **keychain** account (`mcp-secret:`) is the exact, lossless source; the collision is confined to the isolated namespace (it can never reach a provider key) and never surfaces a value, but for the headless env-only path keep secret names charset-distinct in `[A-Z0-9_]`. A `{{secrets.}}` placeholder in an MCP server's `env` resolves **only** within this `mcp-secret:*` namespace (keychain → env var → fail-closed) and is injected **only** into the spawned server's child environment — never a committed YAML, a log, an event, or a `--json` line. The isolation is load-bearing: `{{secrets.*}}` can **never** reach a provider-key account (`{providerId}:{keyId}`) or `search-provider`, so a hostile imported workflow naming a *provider* key resolves an unrelated (normally-empty) `mcp-secret:*` account instead of the real key — closing the exfiltration path under the "a shared/imported workflow is the invite" distribution model. diff --git a/docs/reference/shared-core/mcp-integration.md b/docs/reference/shared-core/mcp-integration.md index 560373fa..8884ca6b 100644 --- a/docs/reference/shared-core/mcp-integration.md +++ b/docs/reference/shared-core/mcp-integration.md @@ -31,7 +31,7 @@ An agent declares the MCP servers it uses in its `mcp_servers` list (see [../con 4. Results stream back as `agent:tool_result` events (see [../contracts/sse-event-schema.md](../contracts/sse-event-schema.md)). 5. The host keeps the MCP server connections alive for the session/run duration, then tears them down. -The `mcp_call` built-in tool is the lower-level path for invoking a registered server's tool by name directly from a `tool` node (see [built-in-tools.md](built-in-tools.md)). +The `mcp_call` built-in tool is the lower-level path for invoking a registered server's tool by name (see [built-in-tools.md](built-in-tools.md)). In Phase-1/2 it is reached as a granted built-in **inside an agent node**; the dedicated `tool`-node form is an engine-internal node type, not yet an authorable workflow node (see [../contracts/workflow-yaml-spec.md](../contracts/workflow-yaml-spec.md#node-types)). ### `McpServerRef` shape @@ -58,7 +58,7 @@ mcp_servers: tools_allowlist: [read_file] # the only field allowed alongside `ref` ``` -The **transport vocabulary** is reconciled to the current MCP spec: `http` is the **Streamable HTTP** transport (the SDK's `StreamableHTTPClientTransport`); `sse` is a **deprecated alias** of `http` (the legacy HTTP+SSE transport, accepted for older servers); `websocket` uses a `wss://` url. A network `url` is SSRF-guarded (below). +The **transport vocabulary** is reconciled to the current MCP spec: `http` is the **Streamable HTTP** transport (the SDK's `StreamableHTTPClientTransport`); `sse` is a **deprecated alias** of `http` (the legacy HTTP+SSE transport, accepted for older servers); `websocket` uses a `wss://` url. A network `url` is SSRF-guarded (below). **`sse` is accepted only on an inline `agent.mcp_servers` entry (the legacy-alias surface) — a `[[mcp_servers]]` config registration takes only `stdio | http | websocket`** (register with `http` instead). The stdio-only fields (`command`/`args`/`env`) are rejected on a network transport, and the network-only fields (`url`/`allow_local_endpoint`) on stdio — symmetric across both schemas. Server **registrations** also live globally in `~/.relavium/config.toml` under repeatable `[[mcp_servers]]` entries, so a server can be registered once and referenced **by name** (`ref:`) from many agents. A referenced server connects **on demand** when an agent that uses it starts; the registration's `autostart` field is accepted by the schema but reserved for a future always-on pool (not acted on in 2.R). The merge of global and project-scoped servers follows the normal config resolution order — see [../contracts/config-spec.md](../contracts/config-spec.md). @@ -73,7 +73,7 @@ Server **registrations** also live globally in `~/.relavium/config.toml` under r > **Not yet shipped (2.R):** tool-list **caching** (re-spawn avoidance via a `(command, args)` hash) is a tracked follow-up — 2.R re-runs `tools/list` on each connect. There is no curated catalog of "built-in" servers either; any server is declared explicitly (a `command: npx …` entry is fetched on first spawn by `npx` itself, not by Relavium). Common choices: `@modelcontextprotocol/server-filesystem`, `…-github`, `…-postgres`, `…-brave-search`, `…-puppeteer`. -On the desktop, stdio MCP servers are managed as child processes by the Rust backend, which owns their lifecycle (start on demand, keep alive for the session, restart on crash). In the CLI and VS Code surfaces the same servers are spawned by the Node.js host. The pooling/lifecycle design narrative is in [../../architecture/shared-core-engine.md](../../architecture/shared-core-engine.md). +On the desktop, stdio MCP servers are managed as child processes by the Rust backend, which owns their lifecycle (start on demand, keep alive for the session, restart on crash). In the CLI and VS Code surfaces the same servers are spawned by the Node.js host. The host-side connection-lifecycle narrative (concurrent fail-loud connect, keep-alive, teardown, and the no-pool/no-cache 2.R reality) is in [../../architecture/shared-core-engine.md](../../architecture/shared-core-engine.md#inbound-mcp-connection-lifecycle). ## Agents as MCP servers (outbound) diff --git a/docs/standards/testing.md b/docs/standards/testing.md index 280b58e7..d125f374 100644 --- a/docs/standards/testing.md +++ b/docs/standards/testing.md @@ -109,10 +109,12 @@ journeys, not exhaustive logic — exhaustive logic belongs in engine unit tests ## Coverage expectations -- `packages/core` and `packages/llm`: high line **and branch** coverage (target ≥ 90%), - because branch coverage is what catches the error/fallback/edge paths that matter here. - Coverage is a floor and a signal, not the goal — an uncovered branch is a question to - answer, not a number to game. +- `packages/core`, `packages/llm`, and `packages/mcp`: high line **and branch** coverage + (enforced floor ≥ 90%), because branch coverage is what catches the error/fallback/edge + paths that matter here — and `packages/mcp` fences a security-critical seam (the SDK + + `node:child_process`) plus the dependency-free JSON-Schema→Zod compiler. Coverage is a + floor and a signal, not the goal — an uncovered branch is a question to answer, not a + number to game. - Every bug fix lands with a regression test that fails before the fix. - Surfaces (`apps/*`, `packages/ui`): smoke + critical-journey coverage; deep logic is pushed down into the engine and tested there. diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index fd3e57fa..a1718a14 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -7,12 +7,14 @@ * or `node:*` ([ADR-0034](../../../docs/decisions/0034-mcp-client-sdk-dependency.md) g3 / * [ADR-0052](../../../docs/decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §1). * - * Exports: the dependency-free JSON-Schema → Zod compiler; the `McpConnection` seam + the stdio SDK adapter - * (`openStdioConnection`) that fences `@modelcontextprotocol/sdk`; the discovered-tool → `ToolDef` shaping - * (`buildServerToolDefs`); and the lifecycle manager — `startMcpClient` returns an `McpClient` (fail-loud - * connect-all + the aggregate `ToolDef`s + the `McpCapability` routing + `close()`). The **CLI host wiring** - * (composing the manager into the engine's `ToolRegistry` + `ToolHost`) + the network transports + secrets - * land in following steps. + * Exports: the dependency-free JSON-Schema → Zod compiler; the `McpConnection` seam + the four SDK adapters + * that fence `@modelcontextprotocol/sdk` — `openStdioConnection` (stdio) plus `openHttpConnection` (Streamable + * HTTP), `openSseConnection` (its legacy alias), and `openWebSocketConnection` (ws); the discovered-tool → + * `ToolDef` shaping (`buildServerToolDefs`); and the lifecycle manager — `startMcpClient` returns an + * `McpClient` (fail-loud connect-all + the aggregate `ToolDef`s + the `McpCapability` routing + `close()`). + * The CLI host wiring that composes the manager into the engine's `ToolRegistry` + `ToolHost`, the SSRF floor, + * and the named-secret resolution all live in `apps/cli` (the host owns the SDK + child processes; this + * package never imports them outward). */ export { compileJsonSchemaToZod, diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts index eb8ad937..034eb6e8 100644 --- a/packages/mcp/src/manager.ts +++ b/packages/mcp/src/manager.ts @@ -68,24 +68,41 @@ export async function startMcpClient(servers: readonly McpServerConfig[]): Promi seenServerIds.add(server.id); } - for (const server of servers) { - try { - const connection = await server.open(); - connections.set(server.id, connection); - const tools = await connection.listTools(); - const shaped = buildServerToolDefs(server.id, tools, server.toolsAllowlist, seenToolIds); - toolDefs.push(...shaped.defs); - toolIdsByServer.set( - server.id, - shaped.defs.map((def) => def.id), - ); - for (const s of shaped.skipped) { - skipped.push({ server: server.id, name: s.name, reason: s.reason }); + // Connect every server CONCURRENTLY: a slow/hung server no longer serializes startup to N×(its connect bound) + // — total startup is now bounded by the SLOWEST single server, not their sum. Each task registers its + // connection as soon as `open()` resolves, so a later `listTools()` failure still has it in `connections` for + // the fail-loud teardown; and each wraps its own failure into a typed, secret-free error carrying the server id. + const settled = await Promise.allSettled( + servers.map(async (server) => { + try { + const connection = await server.open(); + connections.set(server.id, connection); + const tools = await connection.listTools(); + return { server, tools }; + } catch (err) { + throw err instanceof McpError ? err : new McpConnectError(server.id, { cause: err }); } - } catch (err) { - // Fail-loud: tear down everything opened so far, then surface a typed, secret-free error. - await closeAll(connections); - throw err instanceof McpError ? err : new McpConnectError(server.id, { cause: err }); + }), + ); + const failure = settled.find((r): r is PromiseRejectedResult => r.status === 'rejected'); + if (failure !== undefined) { + // Fail-loud: tear down everything opened, then surface the (already typed + secret-free) first failure. + await closeAll(connections); + throw failure.reason; + } + // Assemble the tool defs in DECLARATION order (not connect-completion order) so the cross-server namespacing + + // collision resolution (shared `seenToolIds`, first-wins) stays deterministic regardless of who connected first. + for (const result of settled) { + if (result.status !== 'fulfilled') continue; // unreachable — a rejection already threw above + const { server, tools } = result.value; + const shaped = buildServerToolDefs(server.id, tools, server.toolsAllowlist, seenToolIds); + toolDefs.push(...shaped.defs); + toolIdsByServer.set( + server.id, + shaped.defs.map((def) => def.id), + ); + for (const s of shaped.skipped) { + skipped.push({ server: server.id, name: s.name, reason: s.reason }); } } diff --git a/packages/mcp/src/network-adapters.test.ts b/packages/mcp/src/network-adapters.test.ts index 38da4578..02d0cc49 100644 --- a/packages/mcp/src/network-adapters.test.ts +++ b/packages/mcp/src/network-adapters.test.ts @@ -1,14 +1,21 @@ +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { afterEach, describe, expect, it } from 'vitest'; +import { z } from 'zod'; import { McpError } from './errors.js'; import { openHttpConnection } from './sdk-http.js'; import { openSseConnection } from './sdk-sse.js'; +import { connectSdkTransport } from './sdk-stdio.js'; import { openWebSocketConnection } from './sdk-websocket.js'; /** - * The network transport adapters surface only Relavium shapes; a full connect needs a live server (the e2e - * fixture's job). Here we cover the two host-observable arms without a server: a malformed url is a typed - * connect failure, and the websocket adapter fails LOUD on a runtime with no global `WebSocket` (Node < 22). + * The network transport adapters surface only Relavium shapes; a full LIVE-network connect needs a real server + * (the stdio e2e fixture exercises the same `connectSdkTransport` wrapper over a real transport). Here we cover: + * the two host-observable failure arms without a server (a malformed url is a typed connect failure; the + * websocket adapter fails LOUD on a runtime with no global `WebSocket`, Node < 22), AND the success path that + * every network adapter delegates to — `connectSdkTransport` — over a deterministic in-memory transport pair + * (no port, no network), so the initialize handshake + listTools + callTool + close are genuinely exercised. */ describe('openHttpConnection / openSseConnection (malformed url)', () => { @@ -18,6 +25,33 @@ describe('openHttpConnection / openSseConnection (malformed url)', () => { }); }); +describe('connectSdkTransport (the success path every network adapter delegates to)', () => { + it('runs the initialize handshake, then listTools + callTool round-trip, then close — over an in-memory pair', async () => { + // Deterministic, no network/port: an SDK McpServer over one end of a linked in-memory transport pair; the + // Relavium `connectSdkTransport` wrapper drives the OTHER end exactly as the http/sse/websocket adapters do. + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = new McpServer({ name: 'mem', version: '1.0.0' }); + server.registerTool( + 'echo', + { description: 'echo back', inputSchema: { text: z.string() } }, + ({ text }) => ({ content: [{ type: 'text', text }] }), + ); + await server.connect(serverTransport); + + const conn = await connectSdkTransport('mem', clientTransport); + try { + const tools = await conn.listTools(); + expect(tools.map((t) => t.name)).toEqual(['echo']); + const result = await conn.callTool('echo', { text: 'hi' }); + expect(result.isError).toBe(false); + expect(result.content).toEqual([{ type: 'text', text: 'hi' }]); + } finally { + await conn.close(); + await server.close(); + } + }); +}); + describe('openWebSocketConnection (global WebSocket guard)', () => { const saved = Reflect.get(globalThis, 'WebSocket') as unknown; afterEach(() => { diff --git a/packages/mcp/src/schema-compiler.test.ts b/packages/mcp/src/schema-compiler.test.ts index 5a6ef445..687b2423 100644 --- a/packages/mcp/src/schema-compiler.test.ts +++ b/packages/mcp/src/schema-compiler.test.ts @@ -69,6 +69,16 @@ describe('compileJsonSchemaToZod — supported subset (happy paths)', () => { expect(c.safeParse(43).success).toBe(false); }); + it('INTERSECTS `const` with `enum` when both are present (JSON-Schema: both value-constraints must hold)', () => { + // A consistent pair accepts only the shared member; a contradictory pair accepts nothing (not const-wins). + const consistent = compileOk({ const: 'a', enum: ['a', 'b'] }); + expect(consistent.safeParse('a').success).toBe(true); + expect(consistent.safeParse('b').success).toBe(false); // const narrows the enum + const contradictory = compileOk({ const: 'a', enum: ['b', 'c'] }); + expect(contradictory.safeParse('a').success).toBe(false); // const not in enum ⇒ nothing matches + expect(contradictory.safeParse('b').success).toBe(false); + }); + it('compiles nullability via a [T, null] union and via nullable:true', () => { const viaUnion = compileOk({ type: ['string', 'null'] }); expect(viaUnion.safeParse('x').success).toBe(true); diff --git a/packages/mcp/src/schema-compiler.ts b/packages/mcp/src/schema-compiler.ts index f38435ed..1b7f8797 100644 --- a/packages/mcp/src/schema-compiler.ts +++ b/packages/mcp/src/schema-compiler.ts @@ -148,10 +148,17 @@ function compileNode(node: unknown, budget: Budget, depth: number): z.ZodTypeAny return nullableFlag ? base.nullable() : base; } -/** `const` → a single literal; `enum` → a union of literal members (budget-charged); neither ⇒ `undefined`. */ +/** `const` → a single literal; `enum` → a union of literal members (budget-charged); BOTH ⇒ their INTERSECTION + * (JSON-Schema semantics: both value-constraints must hold, so a contradictory pair accepts nothing); neither ⇒ + * `undefined`. */ function readConstOrEnum(node: Record, budget: Budget): z.ZodTypeAny | undefined { - if ('const' in node) return literalSchema(node['const']); - if ('enum' in node) return enumSchema(node['enum'], budget); + const hasConst = 'const' in node; + const hasEnum = 'enum' in node; + if (hasConst && hasEnum) { + return z.intersection(literalSchema(node['const']), enumSchema(node['enum'], budget)); + } + if (hasConst) return literalSchema(node['const']); + if (hasEnum) return enumSchema(node['enum'], budget); return undefined; } diff --git a/packages/mcp/src/tool-mapping.test.ts b/packages/mcp/src/tool-mapping.test.ts index 64fe885a..fc34a5d9 100644 --- a/packages/mcp/src/tool-mapping.test.ts +++ b/packages/mcp/src/tool-mapping.test.ts @@ -6,7 +6,7 @@ import { McpHostUnavailableError } from './errors.js'; import { buildServerToolDefs } from './tool-mapping.js'; /** A minimal valid dispatch context (dispatch only reads `ctx.signal`; the rest are required-but-inert). */ -function ctx(): ToolDispatchContext { +function ctx(signal?: ToolDispatchContext['signal']): ToolDispatchContext { return { nodeId: 'n1', grantedToolIds: new Set(), @@ -14,6 +14,7 @@ function ctx(): ToolDispatchContext { toolPolicy: {}, fsScope: 'sandboxed', gateApproved: false, + ...(signal === undefined ? {} : { signal }), }; } @@ -67,6 +68,25 @@ describe('buildServerToolDefs', () => { ]); }); + it('forwards the dispatch ctx.signal verbatim to host.mcp.call (the EXACT instance, not just undefined)', async () => { + // The routing test above asserts `signal: undefined`; this pins the forwarding with a REAL AbortSignal, so a + // regression that hard-coded `undefined` (dropping cancellation) would fail here. + const seen: unknown[] = []; + const host: ToolHost = { + mcp: { + call: (_input, signal) => { + seen.push(signal); + return Promise.resolve({ ok: true }); + }, + }, + }; + const realSignal = new AbortController().signal; + const { defs } = buildServerToolDefs('s', [tool('t')]); + await defs[0]!.dispatch({ a: 1 }, host, ctx(realSignal)); + expect(seen).toHaveLength(1); + expect(seen[0]).toBe(realSignal); // the SAME instance, not a copy / not undefined + }); + it('dispatch throws McpHostUnavailableError when the host MCP capability is not wired', () => { const { defs } = buildServerToolDefs('s', [tool('t')]); expect(() => defs[0]!.dispatch({}, {}, ctx())).toThrow(McpHostUnavailableError); diff --git a/packages/shared/src/agent.test.ts b/packages/shared/src/agent.test.ts index 73b9c49d..904258d3 100644 --- a/packages/shared/src/agent.test.ts +++ b/packages/shared/src/agent.test.ts @@ -266,6 +266,47 @@ describe('McpServerRefSchema', () => { ).toBe(true); }); + it('rejects the stdio-only fields `command`/`args` on a network transport (strict + symmetric)', () => { + // A network transport spawns no child, so command/args are inert; rejecting them keeps the schema strict + // and symmetric with the stdio branch (which rejects url/allow_local) and keeps the dedup fingerprint clean. + expect( + McpServerRefSchema.safeParse({ + id: 'docs', + transport: 'http', + url: 'https://docs.example/mcp', + command: 'npx', + }).success, + ).toBe(false); + expect( + McpServerRefSchema.safeParse({ + id: 'docs', + transport: 'websocket', + url: 'wss://docs.example/ws', + args: ['--stdio'], + }).success, + ).toBe(false); + }); + + it('rejects an EMPTY `tools_allowlist` (an empty list silently grants zero tools — omit it to admit all)', () => { + expect( + McpServerRefSchema.safeParse({ + id: 'fs', + transport: 'stdio', + command: 'npx', + tools_allowlist: [], + }).success, + ).toBe(false); + // A non-empty allowlist still parses. + expect( + McpServerRefSchema.safeParse({ + id: 'fs', + transport: 'stdio', + command: 'npx', + tools_allowlist: ['read'], + }).success, + ).toBe(true); + }); + it('rejects `allow_local_endpoint` on a stdio transport (network-only field)', () => { expect( McpServerRefSchema.safeParse({ diff --git a/packages/shared/src/agent.ts b/packages/shared/src/agent.ts index 4031983e..40bfbec6 100644 --- a/packages/shared/src/agent.ts +++ b/packages/shared/src/agent.ts @@ -127,8 +127,9 @@ function validateStdioFields(ref: McpServerRefDraft, ctx: z.RefinementCtx): void } } -/** Inline network (`http`/`sse`/`websocket`): needs a `url` (scheme-checked, credential-free), and rejects `env` - * (no child process to inject into — 2.R wires `env` ONLY into a stdio spawn; network header-auth is a follow-up). */ +/** Inline network (`http`/`sse`/`websocket`): needs a `url` (scheme-checked, credential-free), and rejects the + * stdio-only fields `command`/`args`/`env` — a network transport spawns no child, so they are inert; rejecting + * them keeps the schema strict + symmetric with the stdio branch and keeps `serverFingerprint` clean. */ function validateNetworkFields(ref: McpServerRefDraft, ctx: z.RefinementCtx): void { if (!ref.url) { ctx.addIssue({ @@ -137,13 +138,14 @@ function validateNetworkFields(ref: McpServerRefDraft, ctx: z.RefinementCtx): vo path: ['url'], }); } - if (ref.env !== undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - 'env is not used by a network transport — it is injected only into a stdio server process (network header-auth is a follow-up)', - path: ['env'], - }); + for (const field of ['command', 'args', 'env'] as const) { + if (ref[field] !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${field} is not used by a network transport — it applies only to a stdio server process${field === 'env' ? ' (network header-auth is a follow-up)' : ''}`, + path: [field], + }); + } } if (ref.url !== undefined) { // Per-transport scheme: `websocket` is WS(S); `http`/`sse` are HTTP(S). Also blocks file:/javascript:/etc. @@ -191,7 +193,12 @@ export const McpServerRefSchema = z // SSRF range-block AND permits plaintext for THAT local endpoint only. Network transports only. allow_local_endpoint: z.boolean().optional(), ref: nonEmptyString.optional(), - tools_allowlist: z.array(nonEmptyString).optional(), + // A declared allowlist must name at least one tool — an empty `[]` would silently grant the agent ZERO tools + // from that server (a footgun); OMIT the field to admit all discovered tools. + tools_allowlist: z + .array(nonEmptyString) + .min(1, 'tools_allowlist must name at least one tool (omit it to admit all)') + .optional(), }) .strict() .superRefine((ref, ctx) => { diff --git a/packages/shared/src/config.test.ts b/packages/shared/src/config.test.ts index 458f5d4f..ba3479af 100644 --- a/packages/shared/src/config.test.ts +++ b/packages/shared/src/config.test.ts @@ -139,6 +139,19 @@ describe('config schemas', () => { ).toBe(true); }); + it('rejects the stdio-only fields `command`/`args` on a network MCP registration (symmetric with the inline schema)', () => { + expect( + GlobalConfigSchema.safeParse({ + mcp_servers: [{ name: 'docs', transport: 'http', url: 'https://h/mcp', command: 'npx' }], + }).success, + ).toBe(false); + expect( + GlobalConfigSchema.safeParse({ + mcp_servers: [{ name: 'docs', transport: 'websocket', url: 'wss://h/ws', args: ['--x'] }], + }).success, + ).toBe(false); + }); + it('rejects a stray `url` on a stdio MCP registration (network-only — matches the inline ref schema)', () => { // The inline `McpServerRefSchema` rejects a url on stdio; the registration schema must agree so a committed // config can't carry a mis-declared stdio server (its scheme is irrelevant — its mere presence is the error). diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index 8aa8e90e..98fbd5d9 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -67,13 +67,16 @@ function validateNetworkRegistration(server: McpRegistrationDraft, ctx: z.Refine path: ['url'], }); } - if (server.env !== undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - 'env is not used by a network transport — it is injected only into a stdio server process (network header-auth is a follow-up)', - path: ['env'], - }); + // Reject the stdio-only fields `command`/`args`/`env` on a network registration — symmetric with the inline + // `McpServerRefSchema`; a network transport spawns no child, so they are inert (and would skew the fingerprint). + for (const field of ['command', 'args', 'env'] as const) { + if (server[field] !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${field} is not used by a network transport — it applies only to a stdio server process${field === 'env' ? ' (network header-auth is a follow-up)' : ''}`, + path: [field], + }); + } } if (server.url !== undefined) { const schemeOk = diff --git a/packages/shared/src/content.test.ts b/packages/shared/src/content.test.ts index 126516c8..27f684d7 100644 --- a/packages/shared/src/content.test.ts +++ b/packages/shared/src/content.test.ts @@ -737,6 +737,10 @@ describe('SSRF range-block (isPrivateOrLocalHost)', () => { ['2002:7f00:0001::', '6to4-embedded loopback (= 127.0.0.1)'], ['2002:a9fe:a9fe::', '6to4-embedded cloud metadata (= 169.254.169.254)'], ['2002:0a00:0001::', '6to4-embedded private 10/8 (= 10.0.0.1)'], + // Deprecated IPv4-COMPATIBLE IPv6 (::a.b.c.d, RFC 4291 §2.5.5.1) — high 96 bits zero, NOT ::ffff:. + ['::127.0.0.1', 'IPv4-compatible loopback (= ::7f00:1)'], + ['::169.254.169.254', 'IPv4-compatible cloud metadata (= ::a9fe:a9fe)'], + ['::10.0.0.1', 'IPv4-compatible private 10/8'], ['localhost', 'hostname localhost'], ['myapp.localhost', 'hostname .localhost suffix'], ['myapp.local', 'hostname .local suffix'], @@ -766,6 +770,7 @@ describe('SSRF range-block (isPrivateOrLocalHost)', () => { ['api.openai.com', 'public hostname'], ['2001:4860:4860::8888', 'public IPv6'], ['2002:0808:0808::', '6to4-embedded public 8.8.8.8 — must not over-block'], + ['::8.8.8.8', 'IPv4-compatible public 8.8.8.8 (= ::808:808) — must not over-block'], ['172.15.0.1', 'just below 172.16/12 range'], ['172.32.0.1', 'just above 172.16/12 range'], ['100.63.255.255', 'just below CGNAT range'], diff --git a/packages/shared/src/content.ts b/packages/shared/src/content.ts index af8e31e7..6982135e 100644 --- a/packages/shared/src/content.ts +++ b/packages/shared/src/content.ts @@ -1234,9 +1234,10 @@ function parseIpv6Groups(host: string): number[] | null { /** * Range-check decoded IPv6 groups: unspecified (`::`), loopback (`::1`), link-local (`fe80::/10`), - * unique-local (`fc00::/7`), and the IPv4-embedding forms — IPv4-mapped (`::ffff:a.b.c.d`), NAT64 - * (`64:ff9b::a.b.c.d`), and 6to4 (`2002:a.b.c.d::/48`, the IPv4 in bits 16-47) — which are re-checked - * through the IPv4 rules so a private/loopback IPv4 cannot tunnel past the block inside an IPv6 literal. + * unique-local (`fc00::/7`), and the IPv4-embedding forms — IPv4-mapped (`::ffff:a.b.c.d`), the deprecated + * IPv4-compatible (`::a.b.c.d`, RFC 4291 §2.5.5.1), NAT64 (`64:ff9b::a.b.c.d`), and 6to4 (`2002:a.b.c.d::/48`, + * the IPv4 in bits 16-47) — which are re-checked through the IPv4 rules so a private/loopback IPv4 cannot + * tunnel past the block inside an IPv6 literal. */ function isPrivateIpv6Groups(g: number[]): boolean { if (g.every((x) => x === 0)) { @@ -1253,7 +1254,13 @@ function isPrivateIpv6Groups(g: number[]): boolean { } const zeroHigh = g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0; if (zeroHigh && g[4] === 0 && g[5] === 0xffff) { - return isPrivateOrLocalHost(ipv4FromGroups(g[6] ?? 0, g[7] ?? 0)); // ::ffff:a.b.c.d + return isPrivateOrLocalHost(ipv4FromGroups(g[6] ?? 0, g[7] ?? 0)); // ::ffff:a.b.c.d (IPv4-mapped) + } + if (zeroHigh && g[4] === 0 && g[5] === 0) { + // ::a.b.c.d — deprecated IPv4-COMPATIBLE IPv6 (high 96 bits zero, NOT the ::ffff: mapped form). `::` and + // `::1` already returned above, so any remaining low-32-bits value re-checks the embedded IPv4 — else + // `::127.0.0.1` (`::7f00:1`) / `::169.254.169.254` would tunnel a private/metadata IPv4 past the block. + return isPrivateOrLocalHost(ipv4FromGroups(g[6] ?? 0, g[7] ?? 0)); } if (g[0] === 0x0064 && g[1] === 0xff9b && g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0) { return isPrivateOrLocalHost(ipv4FromGroups(g[6] ?? 0, g[7] ?? 0)); // 64:ff9b::/96 NAT64 diff --git a/vitest.config.ts b/vitest.config.ts index 2c305078..674d7715 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -42,6 +42,7 @@ export default defineConfig({ thresholds: { 'packages/llm/src/**/*.ts': { lines: 90, branches: 90 }, 'packages/core/src/**/*.ts': { lines: 90, branches: 90 }, // engine floor — core landed at 1.L + 'packages/mcp/src/**/*.ts': { lines: 90, branches: 90 }, // the inbound-MCP fence + compiler — 2.R }, }, }, From 86f60b5fb98affd28debd7a7dac3714415ea3d45 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 28 Jun 2026 00:14:47 +0300 Subject: [PATCH 22/24] =?UTF-8?q?fix(cli,shared,mcp,docs):=20PR=20#57=20re?= =?UTF-8?q?view=20round=203=20=E2=80=94=20fixture=20true-offline,=20sse=20?= =?UTF-8?q?symmetry,=20error=20hygiene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified each finding against current code; fixed the still-valid ones (3 skipped with reasons below), minimal + validated. - `agent run --fixture` is now FULLY offline via a clean `disableMcp` flag on BuildChatSessionOptions: buildChatSession skips connectAgentMcp entirely (no config build, no spawn, no dial) — replacing the prior empty-client injection that still ran resolveServerConfigs. The inline `agent.mcp_servers` path can no longer reach a real server in cassette replay. - connectWorkflowMcp's post-augmentation catch closes the client BEST-EFFORT (`.catch(() => undefined)`) so a teardown rejection can't replace the original augmentation error (same posture as the session-host catches). - mcp-secret resolver no longer echoes the raw name into the CliError when it fails the charset guard — it sanitizes (the name just failed `[A-Za-z0-9._-]`, so it may carry control bytes); prevents terminal/log injection. - `sse` is now SYMMETRIC: a `[[mcp_servers]]` registration accepts the deprecated `sse` alias (→ http(s)) just like an inline agent entry — McpServerRegistrationSchema enum + draft updated, config-spec.md/mcp-integration.md reconciled, accept/reject test added. (Reverses the prior round's "inline-only" framing per the maintainer.) - security-review.md: the shared-SSRF summary is now transport-agnostic (the shared rule is the range-block + credential reject; per-transport scheme rules moved to the individual bullets), resolving the conflict with the canonical MCP guidance. Tests: - run.test.ts: the `.cause` assertion moved inside the `isCliError` narrow (no cast). - mcp-servers.test.ts: the three `(err as Error)` casts replaced with `isCliError` narrowing. - network-adapters.test.ts: the no-global-WebSocket path now asserts the thrown instance IS `McpError`, not just the message. - build-engine.test.ts: the MCP-wiring test now PROVES composition — a duplicate mcp tool id is rejected by createToolRegistry (fails if the option were ignored). Skipped (verified non-issues): - index.ts summary: the header was already updated last round to enumerate all four SDK adapters (stdio + http/sse/websocket) — no stale wording remains. - sdk-websocket Node-22 guard: failing loud with a typed McpError on an older runtime is the deliberate, documented design (no `ws` dependency — ADR-0053/deferred); raising the engines floor would drop Node 20.12, gating would silently remove a declared transport. The typed error path is already explicit. - agent.test cleartext-`http://` schema cases: remote-cleartext rejection is the HOST SSRF floor's job (assertSafeNetworkEndpoint), already covered by the floor matrix in mcp-servers.test.ts; the schema intentionally accepts `http://` (allow_local permits local plaintext), so asserting schema-level rejection would be wrong. Refs: ADR-0052, ADR-0053, ADR-0029, ADR-0006 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/chat/session-host.ts | 23 +++++++++++++------ apps/cli/src/commands/agent-run.ts | 10 ++++---- apps/cli/src/commands/run.test.ts | 2 +- apps/cli/src/engine/build-engine.test.ts | 13 +++++++++++ apps/cli/src/engine/mcp-servers.test.ts | 15 +++++++----- apps/cli/src/engine/mcp-servers.ts | 3 ++- apps/cli/src/secrets/mcp-secret.ts | 5 +++- docs/reference/contracts/config-spec.md | 9 ++++---- docs/reference/shared-core/mcp-integration.md | 2 +- docs/standards/security-review.md | 22 +++++++++--------- packages/mcp/src/network-adapters.test.ts | 6 +++-- packages/shared/src/config.test.ts | 15 ++++++++++++ packages/shared/src/config.ts | 9 ++++---- 13 files changed, 90 insertions(+), 44 deletions(-) diff --git a/apps/cli/src/chat/session-host.ts b/apps/cli/src/chat/session-host.ts index f3d2da7e..036cf9d1 100644 --- a/apps/cli/src/chat/session-host.ts +++ b/apps/cli/src/chat/session-host.ts @@ -75,6 +75,12 @@ export interface BuildChatSessionOptions { * entry on the bound agent. Absent ⇒ a `ref` entry fails loud. */ readonly mcpRegistrations?: readonly McpServerRegistration[]; + /** + * Disable inbound MCP entirely for this session — the agent's `mcp_servers` are NOT connected (no config + * build, no spawn, no dial), so the session is fully offline. `relavium agent run --fixture` (cassette replay) + * sets this so a recorded run never touches a real server; the cassette already carries any tool results. + */ + readonly disableMcp?: boolean; /** * Session-scoped `{{ctx.*}}` variables (plaintext, NO secrets — agent-session-spec.md §Tools). `relavium * agent run --input k=v` (2.Q) populates these; a bare `chat` leaves them unset. @@ -202,13 +208,16 @@ export async function buildChatSession(opts: BuildChatSessionOptions): Promise startMcpClient([]) } : {}), + // FULLY offline in `--fixture` (cassette) mode: disable inbound MCP entirely so an agent's inline + // `mcp_servers` are never connected (no config build, no spawn, no dial). The cassette already carries any + // recorded tool results, so the replay needs no live MCP. + ...(offline ? { disableMcp: true } : {}), }); // Render the live stream (NDJSON under --json, else the plain token/tool printer) and capture the turn // outcome — a classified turn failure completes with `session:turn_completed.error`, mapping to exit 1. diff --git a/apps/cli/src/commands/run.test.ts b/apps/cli/src/commands/run.test.ts index 19b77be4..ef4debbf 100644 --- a/apps/cli/src/commands/run.test.ts +++ b/apps/cli/src/commands/run.test.ts @@ -1158,8 +1158,8 @@ describe('runCommand', () => { if (isCliError(caught)) { expect(caught.code).toBe('invalid_invocation'); // a clean exit-2 invocation fault expect(caught.message).toContain('spawn failed for "fs"'); // the secret-free MCP summary + expect(caught.cause).toBeUndefined(); // the opaque MCP cause chain is never attached (narrowed — no cast) } - expect((caught as Error).cause).toBeUndefined(); // the opaque MCP cause chain is never attached expect(engineBuilt).toBe(false); // the connect failed before the engine was ever built (no leak) }); diff --git a/apps/cli/src/engine/build-engine.test.ts b/apps/cli/src/engine/build-engine.test.ts index c5736046..527515cf 100644 --- a/apps/cli/src/engine/build-engine.test.ts +++ b/apps/cli/src/engine/build-engine.test.ts @@ -65,4 +65,17 @@ describe('buildEngine MCP wiring (2.R)', () => { const engine = await buildEngine({ mcp: { toolDefs: defs, capability } }); expect(engine).toBeDefined(); }); + + it('actually COMPOSES the discovered ToolDefs into the registry — a duplicate id is rejected (proves the option is not ignored)', async () => { + // WorkflowEngine is opaque (private fields), so observe the wiring indirectly: createToolRegistry throws on a + // duplicate tool id, and the mcp toolDefs reach it ONLY if `options.mcp` is honored. Passing the SAME def + // twice therefore MUST reject — if the option were silently dropped, this would build cleanly. + const { defs } = buildServerToolDefs('fs', [{ name: 'read', inputSchema: { type: 'object' } }]); + const capability: McpCapability = { + call: () => Promise.resolve({ content: [], isError: false }), + }; + await expect( + buildEngine({ mcp: { toolDefs: [...defs, ...defs], capability } }), + ).rejects.toThrow(/duplicate tool id/); + }); }); diff --git a/apps/cli/src/engine/mcp-servers.test.ts b/apps/cli/src/engine/mcp-servers.test.ts index 5ea4f9eb..b0a76ac8 100644 --- a/apps/cli/src/engine/mcp-servers.test.ts +++ b/apps/cli/src/engine/mcp-servers.test.ts @@ -329,8 +329,9 @@ describe('connectAgentMcp', () => { }); await expect(promise).rejects.toMatchObject({ code: 'invalid_invocation' }); await promise.catch((err: unknown) => { - expect((err as Error).message).toContain('spawn failed for "fs"'); - expect((err as Error).cause).toBeUndefined(); // the opaque cause chain is never attached + if (!isCliError(err)) throw err; // narrow to CliError (no cast); a non-CliError is an unexpected fault + expect(err.message).toContain('spawn failed for "fs"'); + expect(err.cause).toBeUndefined(); // the opaque cause chain is never attached }); }); @@ -504,8 +505,9 @@ describe('connectWorkflowMcp (run path)', () => { // The placeholder must NOT surface in the operator-facing conflict message. await connectWorkflowMcp(divergent, { cwd: '/w', startMcpClient: fakeStart(new Map()) }).catch( (err: unknown) => { - expect((err as Error).message).not.toContain('secrets.gh'); - expect((err as Error).message).not.toContain('secrets.OTHER'); + if (!isCliError(err)) throw err; // narrow to CliError (no cast) + expect(err.message).not.toContain('secrets.gh'); + expect(err.message).not.toContain('secrets.OTHER'); }, ); }); @@ -740,8 +742,9 @@ describe('resolveMcpServerRef (by-name resolution, 2.R Step 4b)', () => { resolveMcpServerRef({ ref: 'nope' }, regs); expect.unreachable('an unknown ref must throw'); } catch (err) { - expect(isCliError(err) && err.code).toBe('invalid_invocation'); - expect((err as Error).message).toContain("ref 'nope' is not registered"); + if (!isCliError(err)) throw err; // narrow to CliError (no cast) + expect(err.code).toBe('invalid_invocation'); + expect(err.message).toContain("ref 'nope' is not registered"); } }); diff --git a/apps/cli/src/engine/mcp-servers.ts b/apps/cli/src/engine/mcp-servers.ts index b7445c56..3e8c4f5c 100644 --- a/apps/cli/src/engine/mcp-servers.ts +++ b/apps/cli/src/engine/mcp-servers.ts @@ -469,7 +469,8 @@ export async function connectWorkflowMcp( // was opened. This guard exists so that if a future throwing transform is added here, the live connection is // torn down rather than leaked (uniform all-or-nothing with the self-cleaning chat builders), not because the // current body throws. Do not assume `withWorkflowMcpGrant` can fail. - await client.close(); + // Best-effort: a teardown rejection must NOT replace the original augmentation error (preserve the primary). + await client.close().catch(() => undefined); throw err; } } diff --git a/apps/cli/src/secrets/mcp-secret.ts b/apps/cli/src/secrets/mcp-secret.ts index b2c19738..264e9b50 100644 --- a/apps/cli/src/secrets/mcp-secret.ts +++ b/apps/cli/src/secrets/mcp-secret.ts @@ -49,9 +49,12 @@ export function createMcpSecretResolver( ): McpSecretResolver { return (name) => { if (!SECRET_NAME.test(name)) { + // The name JUST failed the charset guard, so it may carry control bytes — NEVER echo it raw into a + // terminal/log. Show a sanitized, length-bounded form (the same posture as the unknown-slash echo). + const safe = name.replace(/[^A-Za-z0-9._-]/g, '?').slice(0, 64); throw new CliError( 'invalid_invocation', - `invalid MCP secret name '${name}' — use only letters, digits, '.', '_' or '-'.`, + `invalid MCP secret name '${safe}' — use only letters, digits, '.', '_' or '-'.`, ); } // 1. OS keychain — the isolated `mcp-secret:` account (NEVER a provider key). An unavailable backend diff --git a/docs/reference/contracts/config-spec.md b/docs/reference/contracts/config-spec.md index 907ab76e..55bf3679 100644 --- a/docs/reference/contracts/config-spec.md +++ b/docs/reference/contracts/config-spec.md @@ -82,10 +82,11 @@ autostart = true # accepted, reserved for a future always-on p A `transport = "http"` / `"websocket"` registration requires a `url` (`http(s)` for `http`, `ws(s)` for `websocket`); the url is SSRF-guarded (a private/loopback host is rejected unless `allow_local_endpoint` is set; a remote host must be `https`/`wss`) and must not embed credentials. A registration's transport is -**`stdio | http | websocket`** — the deprecated `sse` alias is accepted only on an inline `agent.mcp_servers` -entry (for older servers), not here; register with `http` instead. The stdio-only fields (`command`/`args`/`env`) -are rejected on a network registration, and the network-only fields (`url`/`allow_local_endpoint`) on a stdio one. -An agent consumes a registration with `- ref: filesystem` (see [../shared-core/mcp-integration.md](../shared-core/mcp-integration.md)). +**`stdio | http | websocket`**, plus the deprecated **`sse`** alias of `http` (accepted for older servers, same +`http(s)` url) — symmetric with an inline `agent.mcp_servers` entry; prefer `http` for new servers. The +stdio-only fields (`command`/`args`/`env`) are rejected on a network registration, and the network-only fields +(`url`/`allow_local_endpoint`) on a stdio one. An agent consumes a registration with `- ref: filesystem` (see +[../shared-core/mcp-integration.md](../shared-core/mcp-integration.md)). ## `project.toml` / `workspace.toml` (project) — keys diff --git a/docs/reference/shared-core/mcp-integration.md b/docs/reference/shared-core/mcp-integration.md index 8884ca6b..f392ec61 100644 --- a/docs/reference/shared-core/mcp-integration.md +++ b/docs/reference/shared-core/mcp-integration.md @@ -58,7 +58,7 @@ mcp_servers: tools_allowlist: [read_file] # the only field allowed alongside `ref` ``` -The **transport vocabulary** is reconciled to the current MCP spec: `http` is the **Streamable HTTP** transport (the SDK's `StreamableHTTPClientTransport`); `sse` is a **deprecated alias** of `http` (the legacy HTTP+SSE transport, accepted for older servers); `websocket` uses a `wss://` url. A network `url` is SSRF-guarded (below). **`sse` is accepted only on an inline `agent.mcp_servers` entry (the legacy-alias surface) — a `[[mcp_servers]]` config registration takes only `stdio | http | websocket`** (register with `http` instead). The stdio-only fields (`command`/`args`/`env`) are rejected on a network transport, and the network-only fields (`url`/`allow_local_endpoint`) on stdio — symmetric across both schemas. +The **transport vocabulary** is reconciled to the current MCP spec: `http` is the **Streamable HTTP** transport (the SDK's `StreamableHTTPClientTransport`); `sse` is a **deprecated alias** of `http` (the legacy HTTP+SSE transport, accepted for older servers, same `http(s)` url); `websocket` uses a `wss://` url. A network `url` is SSRF-guarded (below). The vocabulary is the **same on both surfaces** — an inline `agent.mcp_servers` entry and a `[[mcp_servers]]` config registration both accept `stdio | http | websocket` plus the `sse` alias (prefer `http` for new servers). The stdio-only fields (`command`/`args`/`env`) are rejected on a network transport, and the network-only fields (`url`/`allow_local_endpoint`) on stdio — symmetric across both schemas. Server **registrations** also live globally in `~/.relavium/config.toml` under repeatable `[[mcp_servers]]` entries, so a server can be registered once and referenced **by name** (`ref:`) from many agents. A referenced server connects **on demand** when an agent that uses it starts; the registration's `autostart` field is accepted by the schema but reserved for a future always-on pool (not acted on in 2.R). The merge of global and project-scoped servers follows the normal config resolution order — see [../contracts/config-spec.md](../contracts/config-spec.md). diff --git a/docs/standards/security-review.md b/docs/standards/security-review.md index d0b4903a..9f83b37d 100644 --- a/docs/standards/security-review.md +++ b/docs/standards/security-review.md @@ -125,17 +125,17 @@ A chat-only relaxation of any rule here is a security violation, not a feature. There are **four** outbound-URL paths (the fourth — the multimodal media `url` carrier — is now wired host-side via [ADR-0043](../decisions/0043-media-egress-failover-rematerialization-ssrf.md)'s `fetchMediaBytes`; -see the last bullet), and they share **one** vetted -SSRF range-primitive — never a second hand-rolled parser. The same validation -(HTTPS only, reject non-HTTP(S) schemes and credentials-in-URL, and **block -private/loopback/link-local/metadata ranges** — `127.0.0.0/8`, `::1`, `10/8`, -`172.16/12`, `192.168/16`, `100.64/10` (CGNAT), `169.254/16` incl. the cloud metadata IP `169.254.169.254`, -unless the user has explicitly opted into a local endpoint) applies to **all four** — including the media `url` -carrier fetched by `fetchMediaBytes`. NOTE: the shared primitive is the **range block**; the stronger -**connect-by-validated-IP + per-redirect revalidation** is wired for the media carrier (and is construction-time -for the provider `baseURL` / `http_request`), but the **MCP** path is on the **pre-connect host floor** only -until its dialer lands (see the MCP bullet) — so a DNS-rebind / redirect-to-private window is MCP-specific, not -a property of all four. +see the last bullet), and they share **one** vetted SSRF range-primitive — never a second hand-rolled parser. +The **shared** rule (what every path runs through the one primitive) is the **range block**: reject +private/loopback/link-local/metadata ranges — `127.0.0.0/8`, `::1`, `10/8`, `172.16/12`, `192.168/16`, +`100.64/10` (CGNAT), `169.254/16` incl. the cloud metadata IP `169.254.169.254` (and the IPv6 forms that embed +those) — unless the caller has explicitly opted into a local endpoint, plus reject credentials-in-URL. The +**scheme** requirement is per-path, NOT part of this shared summary: each transport requires its own TLS scheme +(see the individual bullets below — e.g. provider `baseURL` / `http_request` / a remote MCP `url`). NOTE: the +shared primitive is the **range block**; the stronger **connect-by-validated-IP + per-redirect revalidation** is +wired for the media carrier (and is construction-time for the provider `baseURL` / `http_request`), but the +**MCP** path is on the **pre-connect host floor** only until its dialer lands (see the MCP bullet) — so a +DNS-rebind / redirect-to-private window is MCP-specific, not a property of all four. - **Provider `baseURL`.** DeepSeek (and any OpenAI-compatible provider) is reached via a user-supplied `baseURL`. Never let an agent-config URL cause the engine to call an diff --git a/packages/mcp/src/network-adapters.test.ts b/packages/mcp/src/network-adapters.test.ts index 02d0cc49..60a23469 100644 --- a/packages/mcp/src/network-adapters.test.ts +++ b/packages/mcp/src/network-adapters.test.ts @@ -59,9 +59,11 @@ describe('openWebSocketConnection (global WebSocket guard)', () => { Reflect.set(globalThis, 'WebSocket', saved); }); - it('fails loud with a clear, typed error when there is no global WebSocket (Node < 22)', async () => { + it('fails loud with a clear, typed McpError when there is no global WebSocket (Node < 22)', async () => { Reflect.set(globalThis, 'WebSocket', undefined); - await expect(openWebSocketConnection('w', { url: 'wss://host/ws' })).rejects.toThrow( + const promise = openWebSocketConnection('w', { url: 'wss://host/ws' }); + await expect(promise).rejects.toBeInstanceOf(McpError); // a TYPED error, not a plain Error + await expect(promise).rejects.toThrow( /websocket transport requires a global WebSocket \(Node 22\+\)/, ); }); diff --git a/packages/shared/src/config.test.ts b/packages/shared/src/config.test.ts index ba3479af..cd79c55f 100644 --- a/packages/shared/src/config.test.ts +++ b/packages/shared/src/config.test.ts @@ -174,6 +174,21 @@ describe('config schemas', () => { ).toBe(false); }); + it('accepts the deprecated `sse` alias on a registration (symmetric with the inline agent schema, ADR-0052 §5)', () => { + // `sse` is a deprecated alias of `http` (same http(s) url) — a `[[mcp_servers]]` registration accepts it + // just like an inline `agent.mcp_servers` entry, so a server can be registered once and `ref`-reused. + expect( + GlobalConfigSchema.safeParse({ + mcp_servers: [{ name: 'legacy', transport: 'sse', url: 'https://host/sse' }], + }).success, + ).toBe(true); + expect( + GlobalConfigSchema.safeParse({ + mcp_servers: [{ name: 'legacy', transport: 'sse', url: 'wss://host/sse' }], + }).success, + ).toBe(false); // sse → http(s), not ws(s) + }); + it('accepts a `websocket` MCP registration with a ws(s) url, rejecting a non-ws scheme (ADR-0052 §5)', () => { expect( GlobalConfigSchema.safeParse({ diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index 98fbd5d9..3b057d88 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -21,7 +21,7 @@ const SAFE_WS_URL = /^wss?:\/\//i; /** The authored shape `McpServerRegistrationSchema.superRefine` validates (connection fields optional pre-refine). */ interface McpRegistrationDraft { name: string; - transport: 'stdio' | 'http' | 'websocket'; + transport: 'stdio' | 'http' | 'websocket' | 'sse'; command?: string | undefined; args?: readonly string[] | undefined; autostart?: boolean | undefined; @@ -102,14 +102,15 @@ function validateNetworkRegistration(server: McpRegistrationDraft, ctx: z.Refine /** * An MCP server registration (`[[mcp_servers]]`). The transport dictates the required connection field: - * `stdio` needs a `command`; `http` (Streamable HTTP) / `websocket` need a `url`. Reconciled with the agent - * `McpServerRefSchema` to one vocabulary — `stdio | http | websocket` + * `stdio` needs a `command`; `http` (Streamable HTTP) / `websocket` need a `url`; `sse` is the deprecated alias + * of `http` (accepted for older servers, same `http(s)` url). Reconciled with the agent `McpServerRefSchema` to + * one vocabulary — `stdio | http | websocket` (+ the `sse` alias) * ([ADR-0052](../decisions/0052-inbound-mcp-client-package-lifecycle-registration.md) §5). */ export const McpServerRegistrationSchema = z .object({ name: nonEmptyString, - transport: z.enum(['stdio', 'http', 'websocket']), + transport: z.enum(['stdio', 'http', 'websocket', 'sse']), command: z.string().optional(), args: z.array(z.string()).optional(), autostart: z.boolean().optional(), From 4b2250c5e2943b4610457d74831ea336ba774047 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 28 Jun 2026 00:40:51 +0300 Subject: [PATCH 23/24] =?UTF-8?q?refactor(cli):=20agentRunCommand=20cognit?= =?UTF-8?q?ive=20complexity=2018=20=E2=86=92=20under=2015=20(SonarQube)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract two cohesive helpers from `agentRunCommand`, no behavior change: - `resolveOneShotInput` — the two pre-run faults (the `--input` rejection + the empty-stdin guard) plus the stdin read. - `runOneShotTurn` — the renderer selection + the subscribe/start/sendMessage try/catch/finally (including the nested turn-error capture and the best-effort MCP teardown), returning the classified turn-error code. The command body is now a flat orchestration (config → input → providers → build → run), moving the branchy work into the named helpers. Tests unchanged + green. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/commands/agent-run.ts | 73 +++++++++++++++++++----------- 1 file changed, 47 insertions(+), 26 deletions(-) diff --git a/apps/cli/src/commands/agent-run.ts b/apps/cli/src/commands/agent-run.ts index fd22d053..e8317ee8 100644 --- a/apps/cli/src/commands/agent-run.ts +++ b/apps/cli/src/commands/agent-run.ts @@ -4,7 +4,7 @@ import { StringDecoder } from 'node:string_decoder'; import type { SessionStreamHandleEvent } from '@relavium/core'; import { cassetteResolver, loadCassette } from '../chat/fixture.js'; -import { buildChatSession } from '../chat/session-host.js'; +import { buildChatSession, type BuiltChatSession } from '../chat/session-host.js'; import { loadResolvedConfig } from '../config/load.js'; import { surfaceMcpSkipped } from '../engine/mcp-servers.js'; import { createProviderResolver, type ProviderResolver } from '../engine/providers.js'; @@ -58,25 +58,8 @@ export async function agentRunCommand( configPath: deps.global.configPath, }); - // `--input k=v` is REJECTED for now: a session does not yet interpolate `{{ctx.*}}` into the agent prompt - // (the engine passes `system_prompt` verbatim; wiring `resolveTemplate` into the session turn core is a - // deferred, security-relevant change — it would also throw on existing prompts' unresolved placeholders). - // Exposing an inert flag is misleading, so fail loud until the interpolation wiring lands. *(deferred-tasks.md)* - if (args.input.length > 0) { - throw new CliError( - 'invalid_invocation', - '`--input` is not supported yet — a session does not interpolate {{ctx.*}} into the agent prompt (a tracked engine follow-up). Omit it for now.', - ); - } - - // The one-shot prompt is the piped stdin; an empty stdin is a clean invocation fault (nothing to run). - const message = (await readAllStdin(deps.io.stdin)).trim(); - if (message.length === 0) { - throw new CliError( - 'invalid_invocation', - 'no input message — pipe the prompt on stdin (e.g. `echo "…" | relavium agent run `)', - ); - } + // Validate the invocation + read the one-shot prompt from stdin (the two pre-run faults live in the helper). + const message = await resolveOneShotInput(args, deps); // A `--fixture` replays a cassette (offline, no keychain) and takes precedence over any injected/real seam; // otherwise tests inject `providers`, and production resolves keys via the env/keychain (like `relavium run`). @@ -107,15 +90,53 @@ export async function agentRunCommand( // recorded tool results, so the replay needs no live MCP. ...(offline ? { disableMcp: true } : {}), }); - // Render the live stream (NDJSON under --json, else the plain token/tool printer) and capture the turn - // outcome — a classified turn failure completes with `session:turn_completed.error`, mapping to exit 1. + + // Render the live stream + run the single turn + tear down — a classified turn failure maps to exit 1. + const turnErrorCode = await runOneShotTurn(built, message, deps); + return turnErrorCode === undefined ? EXIT_CODES.success : EXIT_CODES.workflowFailed; +} + +/** Validate the one-shot invocation and read the prompt from stdin — the two pre-run faults (exit-2 CliError). */ +async function resolveOneShotInput( + args: AgentRunCommandArgs, + deps: AgentRunCommandDeps, +): Promise { + // `--input k=v` is REJECTED for now: a session does not yet interpolate `{{ctx.*}}` into the agent prompt + // (the engine passes `system_prompt` verbatim; wiring `resolveTemplate` into the session turn core is a + // deferred, security-relevant change — it would also throw on existing prompts' unresolved placeholders). + // Exposing an inert flag is misleading, so fail loud until the interpolation wiring lands. *(deferred-tasks.md)* + if (args.input.length > 0) { + throw new CliError( + 'invalid_invocation', + '`--input` is not supported yet — a session does not interpolate {{ctx.*}} into the agent prompt (a tracked engine follow-up). Omit it for now.', + ); + } + // The one-shot prompt is the piped stdin; an empty stdin is a clean invocation fault (nothing to run). + const message = (await readAllStdin(deps.io.stdin)).trim(); + if (message.length === 0) { + throw new CliError( + 'invalid_invocation', + 'no input message — pipe the prompt on stdin (e.g. `echo "…" | relavium agent run `)', + ); + } + return message; +} + +/** + * Render the live stream (NDJSON under `--json`, else the plain token/tool printer), run the single turn, and + * tear down — returning the classified turn-error code (or `undefined` on success). The whole post-build region + * is inside the try so any fault still hits the finally (the session OWNS the MCP connections; teardown there is + * best-effort and must never override the computed exit code) rather than orphaning the spawned children. + */ +async function runOneShotTurn( + built: BuiltChatSession, + message: string, + deps: AgentRunCommandDeps, +): Promise { let turnErrorCode: string | undefined; const renderer: (event: SessionStreamHandleEvent) => void = deps.global.json ? (event) => deps.io.writeOut(`${JSON.stringify(event)}\n`) : makePlainPrinter(deps.io); - // The session OWNS the live MCP connections (built.closeMcp); the finally tears them down. Keep the whole - // post-build region (skipped-tool note + subscribe) INSIDE the try so any fault there still hits the finally - // rather than orphaning the spawned children (a no-op unsubscribe until the real one is wired below). let unsubscribe: () => void = () => {}; try { surfaceMcpSkipped(deps.io, built.mcpSkipped); @@ -147,7 +168,7 @@ export async function agentRunCommand( ); }); } - return turnErrorCode === undefined ? EXIT_CODES.success : EXIT_CODES.workflowFailed; + return turnErrorCode; } /** Read the whole input stream to EOF as UTF-8 text (the one-shot prompt). Exported for a focused unit test. */ From 2b1f99bc35d5e07a4131d0ee8eb0154429149157 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 28 Jun 2026 00:52:20 +0300 Subject: [PATCH 24/24] =?UTF-8?q?fix(cli):=20chat-ink=20forced-exit=20?= =?UTF-8?q?=E2=80=94=20keep=20the=20bounded=20teardown=20timer=20reference?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second-SIGINT hard-exit path raced `forceExit()` (best-effort MCP teardown) against a FORCE_TEARDOWN_MS fallback timer, but the timer was `.unref()`'d — so it could not keep the event loop alive, and if `forceExit()` hung in a way that also didn't, the loop could drain before the fallback fired, skipping the guaranteed `process.exit(chatEnded)`. Drop `.unref()` so the fallback stays referenced and is guaranteed to fire (then trigger the hard exit). The happy path is unaffected: `forceExit()` resolving first runs `process.exit` immediately, which reclaims the still-pending timer — no spurious wait. (The agent-run cognitive-complexity Sonar finding from the same review batch was already resolved in 4b2250c — `resolveOneShotInput`/`runOneShotTurn` extraction — which Sonar will clear on its next scan post-push.) Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/src/render/tui/chat-ink.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/render/tui/chat-ink.tsx b/apps/cli/src/render/tui/chat-ink.tsx index 75ad811b..d45d16a4 100644 --- a/apps/cli/src/render/tui/chat-ink.tsx +++ b/apps/cli/src/render/tui/chat-ink.tsx @@ -206,9 +206,13 @@ export function driveInk(ctx: ChatDriveContext): Promise { if (forceExit === undefined) { process.exit(EXIT_CODES.chatEnded); } - const bounded = new Promise((resolve) => - setTimeout(resolve, FORCE_TEARDOWN_MS).unref(), - ); + // The fallback timer is deliberately REFERENCED (no `.unref()`): it must keep the event loop alive until it + // fires so the hard exit is GUARANTEED even if `forceExit()` hangs (an unref'd timer could let the loop + // drain first, skipping the exit). On the happy path `forceExit()` resolves first → `process.exit` runs + // immediately and reclaims this still-pending timer, so there is no spurious FORCE_TEARDOWN_MS wait. + const bounded = new Promise((resolve) => { + setTimeout(resolve, FORCE_TEARDOWN_MS); + }); void Promise.race([forceExit().catch(() => undefined), bounded]).finally(() => process.exit(EXIT_CODES.chatEnded), );