Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions src/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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<string, unknown> = {
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) {
Expand Down Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { cwd: string; creating?: Promise<string> }>();
readonly pendingSessions = new Map<string, {
cwd: string;
creating?: Promise<string>;
/** 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<number, PendingTurn>();
/**
Expand Down
53 changes: 53 additions & 0 deletions tests/session-lazy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down