From cfc41c6a639baa40e563d782fe93757a6b56c799 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 04:47:47 +0100 Subject: [PATCH] fix(ai): stdio MCP servers get PATH, HOME, LANG and their configured env only STC_SECRET, database URLs and API keys are no longer inherited by MCP child processes. Test: a fake server reports its environment. Co-Authored-By: Claude Opus 5.5 --- packages/feature-ai/src/mcp/client.ts | 12 ++++++++- packages/feature-ai/test/fixtures/fake-mcp.ts | 1 + packages/feature-ai/test/mcp.test.ts | 25 +++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/feature-ai/src/mcp/client.ts b/packages/feature-ai/src/mcp/client.ts index 1bdd0af..67cb316 100644 --- a/packages/feature-ai/src/mcp/client.ts +++ b/packages/feature-ai/src/mcp/client.ts @@ -25,13 +25,23 @@ function unwrap(r: RpcReply): any { return r.result; } +/** Inherited by stdio MCP servers. Everything else (STC_SECRET, database URLs, API keys) stays with the server. */ +const INHERITED_ENV = ["PATH", "HOME", "LANG"] as const; + +/** The environment for a stdio MCP server: PATH, HOME and LANG from the server, plus exactly the admin-configured variables. */ +export function mcpChildEnv(cfg: McpServerConfig, parent: Record = process.env): Record { + const env: Record = {}; + for (const k of INHERITED_ENV) if (parent[k] !== undefined) env[k] = parent[k]!; + return { ...env, ...(cfg.env ?? {}) }; +} + function stdioTransport(cfg: McpServerConfig, timeoutMs: number): Transport { if (!cfg.command) throw new Error(`MCP server "${cfg.name}" has no command`); const proc = Bun.spawn([cfg.command, ...(cfg.args ?? [])], { stdin: "pipe", stdout: "pipe", stderr: "ignore", - env: { ...process.env, ...(cfg.env ?? {}) }, + env: mcpChildEnv(cfg), }); let nextId = 1; const pending = new Map(); diff --git a/packages/feature-ai/test/fixtures/fake-mcp.ts b/packages/feature-ai/test/fixtures/fake-mcp.ts index 8ac7e4b..7f768dc 100644 --- a/packages/feature-ai/test/fixtures/fake-mcp.ts +++ b/packages/feature-ai/test/fixtures/fake-mcp.ts @@ -8,6 +8,7 @@ export function handle(msg: any): any | null { case "tools/list": return { jsonrpc: "2.0", id: msg.id, result: { tools: [{ name: "echo", description: "Echo text back", inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] } }] } }; case "tools/call": + if (msg.params?.name === "env") return { jsonrpc: "2.0", id: msg.id, result: { content: [{ type: "text", text: JSON.stringify(process.env) }] } }; if (msg.params?.name !== "echo") return { jsonrpc: "2.0", id: msg.id, error: { code: -32602, message: `Unknown tool ${msg.params?.name}` } }; return { jsonrpc: "2.0", id: msg.id, result: { content: [{ type: "text", text: `echo: ${msg.params.arguments?.text}` }] } }; default: diff --git a/packages/feature-ai/test/mcp.test.ts b/packages/feature-ai/test/mcp.test.ts index 47b78d9..18356d5 100644 --- a/packages/feature-ai/test/mcp.test.ts +++ b/packages/feature-ai/test/mcp.test.ts @@ -55,3 +55,28 @@ test("server selection and tool names", () => { expect(mcpToolName("my server", "read file!")).toBe("mcp_my_server__read_file_"); expect(mcpToolName("a".repeat(40), "b".repeat(40)).length).toBeLessThanOrEqual(64); }); + +test("stdio: the child gets a minimal environment plus only its configured variables", async () => { + const saved = { ...process.env }; + Object.assign(process.env, { + STC_SECRET: "server-secret", STC_DATABASE_URL: "postgres://u:p@db/x", DATABASE_URL: "postgres://u:p@db/y", + ANTHROPIC_API_KEY: "sk-ant-x", MY_SECRET: "s", AWS_SECRET_ACCESS_KEY: "aws", LANG: "C.UTF-8", + }); + try { + const c = await connectMcp({ id: "fake", name: "Fake", transport: "stdio", command: process.execPath, args: [FAKE], env: { MCP_TOKEN: "t", STC_EXPLICIT: "configured" } }); + try { + const env = JSON.parse(await c.call("env", {})) as Record; + for (const k of ["STC_SECRET", "STC_DATABASE_URL", "DATABASE_URL", "ANTHROPIC_API_KEY", "MY_SECRET", "AWS_SECRET_ACCESS_KEY"]) expect(env[k]).toBeUndefined(); + expect(env.MCP_TOKEN).toBe("t"); + expect(env.STC_EXPLICIT).toBe("configured"); // explicitly configured by the admin + expect(env.LANG).toBe("C.UTF-8"); + if (saved.PATH) expect(env.PATH).toBe(saved.PATH); + if (saved.HOME) expect(env.HOME).toBe(saved.HOME); + const unexpected = Object.keys(env).filter((k) => !["PATH", "HOME", "LANG", "MCP_TOKEN", "STC_EXPLICIT"].includes(k)); + expect(unexpected.filter((k) => /STC_|DATABASE|SECRET|_KEY$/i.test(k))).toEqual([]); + } finally { await c.close(); } + } finally { + for (const k of Object.keys(process.env)) if (!(k in saved)) delete process.env[k]; + Object.assign(process.env, saved); + } +});