From bafe14b41c5f880796805e201f5f81827e848dc0 Mon Sep 17 00:00:00 2001 From: lianxin255 <39765770+lianxin255@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:22:57 +0800 Subject: [PATCH 1/3] fix: forward ACP session mcpServers to the backend's session create/resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit session/new and session/resume accepted an mcpServers parameter but never read it, so client-provided stdio servers were silently dropped — the session only ever saw the local ~/.zcode-configured servers (verified with a trivial stdio echo server: the tool never appeared, while local servers loaded fine). The backend's session/create and session/resume schemas accept the ACP mcpServers array shape verbatim (stdio entries carry command/args/env as [{name,value}] pairs; remote entries carry type/url; the stdio branch rejects a "type" key, so entries are passed through unchanged). This change - stores params.mcpServers on the lazy pending session in session/new and replays it into session/create when the session materializes, - forwards params.mcpServers on session/resume (schema-verified: an unknown key is rejected before the session lookup, and mcpServers is not), Verified end-to-end through Multica (daemon -> bridge -> zcode 0.16.3): an agent-configured echo server now appears as mcp__echo__mcp_echo next to the runtime's own local servers (context7, web_reader, ...), i.e. the merge is additive and the local config is untouched. Closes #42 --- src/handlers/session.ts | 23 +++++++++++++++++++++-- src/server.ts | 11 ++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 4a70c79..0efbb49 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 and merges the servers alongside its own local config + // (client entries winning on name clash is the backend's rule; entries + // here are only ever additive from this side). + 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..d2225bd 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?: unknown[]; + }>(); /** Currently running turns, keyed by the ACP request id. */ readonly pendingTurns = new Map(); /** From 488ddaba28e4dd2c3061b78641e8f394b0a2f526 Mon Sep 17 00:00:00 2001 From: lianxin255 <39765770+lianxin255@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:40:35 +0800 Subject: [PATCH 2/3] fix: type the pending-session MCP servers and soften an unverified claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mcpServers on the pending session is acp.McpServer[] (the SDK's own union) instead of unknown[] - the create-path comment asserted "client entries winning on name clash is the backend's rule" — only the additive merge was verified, so the comment now says exactly that and leaves clash behaviour unasserted --- src/handlers/session.ts | 6 +++--- src/server.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 0efbb49..f4f233c 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -158,9 +158,9 @@ export async function ensureRealSession(server: ZcodeAcpServer, acpSid: string): 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 and merges the servers alongside its own local config - // (client entries winning on name clash is the backend's rule; entries - // here are only ever additive from this side). + // 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", diff --git a/src/server.ts b/src/server.ts index d2225bd..982dfde 100644 --- a/src/server.ts +++ b/src/server.ts @@ -75,7 +75,7 @@ export class ZcodeAcpServer { * 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?: unknown[]; + mcpServers?: acp.McpServer[]; }>(); /** Currently running turns, keyed by the ACP request id. */ readonly pendingTurns = new Map(); From fc10bab6b357d43097354eae5f03629eef4492ab Mon Sep 17 00:00:00 2001 From: lianxin255 <39765770+lianxin255@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:20:28 +0800 Subject: [PATCH 3/3] test: regression coverage for mcpServers forwarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tests per the contributing guide's fail-before/pass-after rule: - client mcpServers from session/new are replayed verbatim into the backend's session/create when the lazy placeholder materializes - session/create omits the key entirely when none were provided (no empty-array noise for the backend's strict schema) - resume-provided mcpServers reach the backend's session/resume 461 → 464 tests, all green. --- tests/session-lazy.test.ts | 53 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) 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"));