Files
STCourses/packages/feature-ai/test/mcp.test.ts
T
Lucas WintherandClaude Opus 5.5 cfc41c6a63 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 <[email protected]>
2026-10-30 04:47:47 +01:00

83 lines
4.2 KiB
TypeScript

import { afterAll, expect, test } from "bun:test";
import { join } from "node:path";
import { connectMcp, mcpServersFor, mcpToolName } from "../src/mcp/client";
import { handle } from "./fixtures/fake-mcp";
const FAKE = join(import.meta.dir, "fixtures/fake-mcp.ts");
test("stdio: lists and calls tools", async () => {
const c = await connectMcp({ id: "fake", name: "Fake", transport: "stdio", command: process.execPath, args: [FAKE] });
try {
expect((await c.tools()).map((t) => t.name)).toEqual(["echo"]);
expect(await c.call("echo", { text: "hi" })).toBe("echo: hi");
await expect(c.call("nope", {})).rejects.toThrow(/Unknown tool/);
} finally { await c.close(); }
});
test("stdio: a server that exits rejects instead of hanging", async () => {
await expect(connectMcp({ id: "x", name: "X", transport: "stdio", command: process.execPath, args: [FAKE, "--exit-immediately"] }, { timeoutMs: 5000 })).rejects.toThrow();
});
const sessions: (string | null)[] = [];
const server = Bun.serve({
port: 0,
async fetch(req) {
if (req.method === "DELETE") return new Response(null, { status: 204 });
const msg = await req.json();
sessions.push(req.headers.get("mcp-session-id"));
const reply = handle(msg);
if (!reply) return new Response(null, { status: 202 });
const headers: Record<string, string> = msg.method === "initialize" ? { "mcp-session-id": "sess-42" } : {};
if (msg.method === "tools/call") {
return new Response(`event: message\ndata: ${JSON.stringify(reply)}\n\n`, { headers: { ...headers, "content-type": "text/event-stream" } });
}
return Response.json(reply, { headers });
},
});
afterAll(() => server.stop(true));
test("streamable HTTP: JSON and SSE replies, session id echoed", async () => {
const c = await connectMcp({ id: "web", name: "Web", transport: "http", url: `http://127.0.0.1:${server.port}/mcp` });
expect((await c.tools()).map((t) => t.name)).toEqual(["echo"]);
expect(await c.call("echo", { text: "over http" })).toBe("echo: over http");
await c.close();
expect(sessions[0]).toBeNull();
expect(sessions.slice(1).every((s) => s === "sess-42")).toBe(true);
});
test("server selection and tool names", () => {
const servers = [
{ id: "g", name: "G", transport: "http" as const, url: "http://x" },
{ id: "s", name: "S", transport: "http" as const, url: "http://y", courseIds: ["sample"] },
{ id: "o", name: "O", transport: "http" as const, url: "http://z", courseIds: ["other"] },
];
expect(mcpServersFor("sample", servers).map((s) => s.id)).toEqual(["g", "s"]);
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<string, string>;
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);
}
});