feat(ai): MCP client over stdio and streamable HTTP

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 03:19:13 +01:00
co-authored by Claude Opus 5.5
parent 50e1e49b6d
commit 24cd6329cb
4 changed files with 278 additions and 0 deletions
+1
View File
@@ -6,3 +6,4 @@ export * from "./settings";
export * from "./providers";
export * from "./retrieval/course-index";
export { Bm25, tokenize } from "./retrieval/bm25";
export * from "./mcp/client";
+188
View File
@@ -0,0 +1,188 @@
import type { McpServerConfig } from "../settings";
import { readSse } from "../providers/sse";
export interface McpTool { name: string; description: string; inputSchema: Record<string, unknown> }
export interface McpClient {
tools(): Promise<McpTool[]>;
call(name: string, args: unknown): Promise<string>;
close(): Promise<void>;
}
const PROTOCOL_VERSION = "2025-06-18";
const CLIENT_INFO = { name: "stcourses", version: "1.0.0" };
type RpcReply = { id?: number | string; result?: any; error?: { code: number; message: string } };
interface Transport { request(method: string, params: unknown): Promise<any>; notify(method: string, params?: unknown): Promise<void>; close(): Promise<void> }
const withTimeout = <T>(p: Promise<T>, ms: number, what: string): Promise<T> =>
new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error(`MCP ${what} timed out after ${ms} ms`)), ms);
p.then((v) => { clearTimeout(t); resolve(v); }, (e) => { clearTimeout(t); reject(e); });
});
function unwrap(r: RpcReply): any {
if (r.error) throw new Error(`MCP error ${r.error.code}: ${r.error.message}`);
return r.result;
}
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 ?? {}) },
});
let nextId = 1;
const pending = new Map<number, { resolve(v: RpcReply): void; reject(e: Error): void }>();
let closed = false;
const failAll = (e: Error) => { for (const p of pending.values()) p.reject(e); pending.clear(); };
(async () => {
const decoder = new TextDecoder();
let buf = "";
try {
for await (const chunk of proc.stdout as unknown as AsyncIterable<Uint8Array>) {
buf += decoder.decode(chunk, { stream: true });
let nl: number;
while ((nl = buf.indexOf("\n")) !== -1) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
let msg: RpcReply;
try { msg = JSON.parse(line); } catch { continue; }
if (typeof msg.id === "number" && pending.has(msg.id)) {
pending.get(msg.id)!.resolve(msg);
pending.delete(msg.id);
}
}
}
} catch {}
closed = true;
failAll(new Error(`MCP server "${cfg.name}" exited`));
})();
proc.exited.then((code) => { closed = true; failAll(new Error(`MCP server "${cfg.name}" exited with code ${code}`)); });
const send = (obj: unknown) => {
if (closed) throw new Error(`MCP server "${cfg.name}" is not running`);
proc.stdin.write(JSON.stringify(obj) + "\n");
proc.stdin.flush();
};
return {
async request(method, params) {
const id = nextId++;
const reply = new Promise<RpcReply>((resolve, reject) => pending.set(id, { resolve, reject }));
send({ jsonrpc: "2.0", id, method, params });
return unwrap(await withTimeout(reply, timeoutMs, method));
},
async notify(method, params) { send({ jsonrpc: "2.0", method, ...(params ? { params } : {}) }); },
async close() {
closed = true;
try { proc.stdin.end(); } catch {}
proc.kill();
await withTimeout(proc.exited, 2000, "shutdown").catch(() => proc.kill(9));
},
};
}
function httpTransport(cfg: McpServerConfig, timeoutMs: number, doFetch: typeof fetch): Transport {
if (!cfg.url) throw new Error(`MCP server "${cfg.name}" has no URL`);
const url = cfg.url;
let session: string | null = null;
let nextId = 1;
const post = async (body: unknown, signal: AbortSignal) => {
const headers: Record<string, string> = {
"content-type": "application/json",
accept: "application/json, text/event-stream",
"mcp-protocol-version": PROTOCOL_VERSION,
...(cfg.headers ?? {}),
};
if (session) headers["mcp-session-id"] = session;
const res = await doFetch(url, { method: "POST", headers, body: JSON.stringify(body), signal });
const sid = res.headers.get("mcp-session-id");
if (sid) session = sid;
return res;
};
return {
async request(method, params) {
const id = nextId++;
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), timeoutMs);
try {
const res = await post({ jsonrpc: "2.0", id, method, params }, ctl.signal);
if (!res.ok) throw new Error(`MCP HTTP ${res.status} from "${cfg.name}"`);
const type = res.headers.get("content-type") ?? "";
if (type.includes("text/event-stream") && res.body) {
for await (const { data } of readSse(res.body)) {
let msg: RpcReply;
try { msg = JSON.parse(data); } catch { continue; }
if (msg.id === id) { ctl.abort(); return unwrap(msg); }
}
throw new Error(`MCP stream from "${cfg.name}" ended without a reply`);
}
const msg = await res.json();
const reply = Array.isArray(msg) ? msg.find((m: RpcReply) => m.id === id) : msg;
return unwrap(reply ?? { error: { code: -1, message: "no reply" } });
} catch (e) {
if (ctl.signal.aborted && (e as Error).name === "AbortError") throw new Error(`MCP ${method} timed out after ${timeoutMs} ms`);
throw e;
} finally { clearTimeout(t); }
},
async notify(method, params) {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), timeoutMs);
try { await post({ jsonrpc: "2.0", method, ...(params ? { params } : {}) }, ctl.signal); } finally { clearTimeout(t); }
},
async close() {
if (!session) return;
const headers: Record<string, string> = { "mcp-session-id": session, ...(cfg.headers ?? {}) };
await doFetch(url, { method: "DELETE", headers }).catch(() => {});
},
};
}
/** Connects and initializes an MCP server (stdio or streamable HTTP). */
export async function connectMcp(cfg: McpServerConfig, opts: { fetch?: typeof fetch; timeoutMs?: number } = {}): Promise<McpClient> {
const timeoutMs = opts.timeoutMs ?? 20_000;
const t = cfg.transport === "stdio" ? stdioTransport(cfg, timeoutMs) : httpTransport(cfg, timeoutMs, opts.fetch ?? fetch);
try {
await t.request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: CLIENT_INFO });
await t.notify("notifications/initialized");
} catch (e) {
await t.close().catch(() => {});
throw e;
}
return {
async tools() {
const out: McpTool[] = [];
let cursor: string | undefined;
do {
const r = await t.request("tools/list", cursor ? { cursor } : {});
for (const tool of r?.tools ?? []) out.push({ name: tool.name, description: tool.description ?? "", inputSchema: tool.inputSchema ?? { type: "object" } });
cursor = r?.nextCursor;
} while (cursor);
return out;
},
async call(name, args) {
const r = await t.request("tools/call", { name, arguments: args ?? {} });
const text = (r?.content ?? [])
.map((c: any) => (c.type === "text" ? c.text : c.type === "resource" ? (c.resource?.text ?? `[resource ${c.resource?.uri}]`) : `[${c.type} content]`))
.join("\n");
const body = text || (r?.structuredContent ? JSON.stringify(r.structuredContent) : "");
if (r?.isError) throw new Error(body || "tool failed");
return body;
},
close: () => t.close(),
};
}
/** Global servers (no courseIds) plus servers that list this course. */
export function mcpServersFor(courseId: string, servers: McpServerConfig[]): McpServerConfig[] {
return servers.filter((s) => !s.courseIds?.length || s.courseIds.includes(courseId));
}
/** Provider-safe tool name (^[a-zA-Z0-9_-]{1,64}$) for an MCP tool. */
export function mcpToolName(serverId: string, tool: string): string {
const clean = (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, "_");
return `mcp_${clean(serverId).slice(0, 20)}__${clean(tool)}`.slice(0, 64);
}
+32
View File
@@ -0,0 +1,32 @@
// A fake MCP server for tests. stdio: newline-delimited JSON-RPC on stdin/stdout.
// Exported `handle` is reused by the HTTP fake in mcp.test.ts.
export function handle(msg: any): any | null {
if (msg.id === undefined) return null; // notification
switch (msg.method) {
case "initialize":
return { jsonrpc: "2.0", id: msg.id, result: { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "fake", version: "1" } } };
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 !== "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:
return { jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "Method not found" } };
}
}
if (import.meta.main) {
if (process.argv.includes("--exit-immediately")) process.exit(3);
let buf = "";
for await (const chunk of Bun.stdin.stream()) {
buf += new TextDecoder().decode(chunk);
let nl: number;
while ((nl = buf.indexOf("\n")) !== -1) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
const reply = handle(JSON.parse(line));
if (reply) process.stdout.write(JSON.stringify(reply) + "\n");
}
}
}
+57
View File
@@ -0,0 +1,57 @@
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);
});