diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 4a70c79..f4f233c 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -102,7 +102,7 @@ export async function newSession( // Placeholder id — the client addresses this session with it until the // backend session materializes; never shown in session/list. const acpSid = randomUUID(); - server.pendingSessions.set(acpSid, { cwd }); + server.pendingSessions.set(acpSid, { cwd, mcpServers: params.mcpServers }); // Durable alias so the placeholder survives a bridge restart and session/ // resume can still resolve it (best-effort; failures are swallowed inside // the store). @@ -156,10 +156,23 @@ export async function ensureRealSession(server: ZcodeAcpServer, acpSid: string): // promise is stored before any concurrent caller can observe the entry. const creating = (async () => { const backend = server.ensureBackend(); + // Client-provided MCP servers (ACP session/new mcpServers) ride along + // when the lazy session materializes. The backend accepts the ACP array + // shape verbatim; the verified merge behaviour is additive (client + // entries appear next to the runtime's own local config). Same-name + // clash behaviour is the backend's own and unasserted here. + const createParams: Record = { + workspace: workspaceFor(pending.cwd), + mode: "yolo", + }; + if (pending.mcpServers && pending.mcpServers.length > 0) { + createParams.mcpServers = pending.mcpServers; + log(`session/create carrying ${pending.mcpServers.length} client MCP server(s)`); + } const resp = await backend.request( server.nextId(), "session/create", - { workspace: workspaceFor(pending.cwd), mode: "yolo" }, + createParams, 15000, ); if (resp.error) { @@ -292,6 +305,12 @@ export async function resumeSession( sessionId: zcodeSid, workspace: workspaceFor(cwd), }; + // ACP session/resume may also carry mcpServers; the backend's resume + // schema accepts the same array shape (verified: an unknown key would be + // rejected before the session lookup). + if (params.mcpServers && params.mcpServers.length > 0) { + zcParams.mcpServers = params.mcpServers; + } const runtimeModel = buildResumeRuntimeModel(); if (runtimeModel !== null) zcParams.runtimeModel = runtimeModel; // Push the provider registry BEFORE resume: a resumed session may carry a diff --git a/src/server.ts b/src/server.ts index 5d499f4..982dfde 100644 --- a/src/server.ts +++ b/src/server.ts @@ -67,7 +67,16 @@ export class ZcodeAcpServer { * create is running, so concurrent first-uses (e.g. a raced double prompt) * share one `session/create` instead of creating two backend sessions. */ - readonly pendingSessions = new Map }>(); + readonly pendingSessions = new Map; + /** Client-provided MCP servers from session/new, replayed verbatim into + * the backend's session/create when the lazy session materializes. The + * backend's mcpServers schema matches the ACP array shape (stdio entries + * carry command/args/env; remote entries carry type/url), so entries are + * passed through unchanged. */ + mcpServers?: acp.McpServer[]; + }>(); /** Currently running turns, keyed by the ACP request id. */ readonly pendingTurns = new Map(); /** diff --git a/tests/session-lazy.test.ts b/tests/session-lazy.test.ts index 124d990..84b84f4 100644 --- a/tests/session-lazy.test.ts +++ b/tests/session-lazy.test.ts @@ -225,6 +225,59 @@ describe("resumeSession with lazy placeholders", () => { expect(out.modes.currentModeId).toBe("yolo"); }); + it("replays client-provided mcpServers into the backend session/create", async () => { + // Regression: session/new accepted an mcpServers parameter but never read + // it, so client-configured stdio servers were silently dropped. The lazy + // placeholder must carry them into session/create verbatim. + const server = new ZcodeAcpServer(); + const mcpServers = [ + { name: "echo", command: "node", args: ["/tmp/mcp-echo.mjs"], env: [] }, + ]; + const resp = await newSession( + server, + { cwd: "/tmp/ws", mcpServers } as acp.NewSessionRequest, + ); + expect(server.pendingSessions.get(resp.sessionId)).toMatchObject({ mcpServers }); + + const { backend, calls } = fakeBackend(); + server.backend = backend; + await ensureRealSession(server, resp.sessionId); + + const creates = calls.filter((c) => c.method === "session/create"); + expect(creates).toHaveLength(1); + expect(creates[0].params).toMatchObject({ mode: "yolo", mcpServers }); + }); + + it("omits mcpServers from session/create when the client provided none", async () => { + const server = new ZcodeAcpServer(); + const resp = await newSession(server, newSessionParams("/tmp/ws")); + const { backend, calls } = fakeBackend(); + server.backend = backend; + await ensureRealSession(server, resp.sessionId); + + const creates = calls.filter((c) => c.method === "session/create"); + expect(creates[0].params).not.toHaveProperty("mcpServers"); + }); + + it("forwards resume-provided mcpServers to the backend session/resume", async () => { + // Regression companion: ACP session/resume also carries mcpServers; the + // backend re-connects them as part of the resume. + const server = new ZcodeAcpServer(); + const { backend, calls } = fakeBackend(); + server.backend = backend; + const mcpServers = [{ name: "echo", command: "node", args: [], env: [] }]; + + await resumeSession( + server, + { sessionId: "sess_real_1", cwd: "/tmp/ws", mcpServers } as acp.ResumeSessionRequest, + {} as acp.AgentContext, + ); + + const resumes = calls.filter((c) => c.method === "session/resume"); + expect(resumes).toHaveLength(1); + expect(resumes[0].params).toMatchObject({ sessionId: "sess_real_1", mcpServers }); + }); + it("resumes an already-materialized placeholder without backend resume", async () => { const server = new ZcodeAcpServer(); const resp = await newSession(server, newSessionParams("/tmp/ws"));