SysDeck/klanker-gate/tests/integration/mcp_hardening_test.ts

219 lines
7.4 KiB
TypeScript
Executable File

// Wave-4 MCP hardening (decisions D10/D11): stdio transport is disabled by
// default behind FROSTY_MCP_ALLOW_STDIO, http-sse is the default transport
// for new configs, persisted configs never brick boot, and one dead server
// no longer blocks syncing the rest.
import { assert, assertEquals, assertRejects, assertThrows } from "@std/assert";
import {
MCPClient,
MCPClientConfigSchema,
MCPError,
} from "../../packages/mcp/src/client.ts";
import { MCPRegistry } from "../../packages/mcp/src/registry.ts";
import { MCPHealthMonitor } from "../../packages/mcp/src/monitor.ts";
import { createHandler } from "../../apps/gateway/main.ts";
import { type AppContext, VERSION } from "../../apps/gateway/context.ts";
import { ProviderManager } from "../../packages/providers/src/mod.ts";
import { Metrics } from "../../packages/telemetry/src/metrics.ts";
import { LogBus } from "../../packages/telemetry/src/logbus.ts";
import { VirtualKeyManager } from "../../packages/governance/src/virtual_keys.ts";
import { PluginManager } from "../../packages/plugins/src/lifecycle.ts";
const STDIO_CONFIG = {
id: "local-tool",
transport: "stdio" as const,
command: [Deno.execPath(), "run", "-"],
};
Deno.test("stdio configs are rejected by default with an actionable error", () => {
const registry = new MCPRegistry();
const error = assertThrows(
() => registry.upsert(STDIO_CONFIG),
MCPError,
"FROSTY_MCP_ALLOW_STDIO",
);
assertEquals(error.code, 403);
assertEquals(registry.list().length, 0);
});
Deno.test("allowStdio option re-enables stdio upserts", () => {
const registry = new MCPRegistry([], undefined, { allowStdio: true });
const client = registry.upsert(STDIO_CONFIG);
assertEquals(client.config.transport, "stdio");
});
Deno.test("persisted stdio clients are skipped at load, not fatal", () => {
// Upgrade path: a stdio client stored before the default flipped must not
// prevent boot, and the surviving HTTP clients must still register.
const registry = new MCPRegistry([
STDIO_CONFIG,
{ id: "http-ok", url: "http://127.0.0.1:1", transport: "streamable-http" },
]);
assertEquals(registry.list().map((c) => c.config.id), ["http-ok"]);
});
Deno.test("new configs default to http-sse; stored transports round-trip", () => {
const fresh = MCPClientConfigSchema.parse({ id: "a", url: "http://x" });
assertEquals(fresh.transport, "http-sse");
// Persisted records carry a materialized transport and must not be
// rewritten by the new default on re-parse.
const stored = MCPClientConfigSchema.parse({
id: "b",
url: "http://x",
transport: "streamable-http",
});
assertEquals(stored.transport, "streamable-http");
});
/** Minimal streamable-http MCP double answering initialize/tools/list. */
function healthyMCP() {
const server = Deno.serve({ port: 0, onListen: () => {} }, async (req) => {
const body = await req.json() as { id?: number; method: string };
if (body.id === undefined) {
return new Response(null, { status: 202 });
}
const result = body.method === "tools/list"
? { tools: [{ name: "ping", annotations: { readOnlyHint: true } }] }
: { protocolVersion: "2025-06-18" };
return Response.json({ jsonrpc: "2.0", id: body.id, result });
});
return {
url: `http://127.0.0.1:${(server.addr as Deno.NetAddr).port}`,
shutdown: () => server.shutdown(),
};
}
Deno.test("syncAll isolates per-client failures", async () => {
const upstream = healthyMCP();
const registry = new MCPRegistry([
{ id: "good", url: upstream.url, transport: "streamable-http" },
{
id: "dead",
url: "http://127.0.0.1:9",
transport: "streamable-http",
requestTimeoutMs: 500,
},
]);
try {
const summary = await registry.syncAll();
assertEquals(summary["good"], 1); // synced despite the dead sibling
assertEquals(summary["dead"], -1); // failure reported, not thrown
assertEquals(registry.toolCatalog().map((t) => t.name), ["ping"]);
} finally {
await upstream.shutdown();
}
});
function apiContext(): AppContext {
const mcp = new MCPRegistry();
return {
providers: new ProviderManager(),
metrics: new Metrics(),
logBus: new LogBus(),
virtualKeys: new VirtualKeyManager(),
mcp,
mcpMonitor: new MCPHealthMonitor(mcp),
plugins: new PluginManager(),
toolExecutor: mcp.executor(),
version: VERSION,
};
}
Deno.test("POST /api/mcp/clients with stdio returns a governed 400", async () => {
const handler = createHandler(apiContext());
const res = await handler(
new Request("http://gateway.test/api/mcp/clients", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(STDIO_CONFIG),
}),
);
assertEquals(res.status, 400);
const body = await res.json() as { error: { message: string } };
assert(body.error.message.includes("FROSTY_MCP_ALLOW_STDIO"));
});
Deno.test("http-sse endpoint announcement times out instead of hanging", async () => {
// A server that accepts the GET but never announces an endpoint (e.g. a
// streamable-http-only server) must fail within requestTimeoutMs — a hang
// here would block boot, sync, and health checks (now that http-sse is
// the default transport).
let open: ReadableStreamDefaultController<Uint8Array> | undefined;
const server = Deno.serve({ port: 0, onListen: () => {} }, () =>
new Response(
new ReadableStream<Uint8Array>({
start(c) {
open = c;
},
cancel() {
open = undefined;
},
}),
{ headers: { "Content-Type": "text/event-stream" } },
));
const client = new MCPClient(MCPClientConfigSchema.parse({
id: "silent",
url: `http://127.0.0.1:${(server.addr as Deno.NetAddr).port}`,
transport: "http-sse",
requestTimeoutMs: 300,
}));
try {
await assertRejects(() => client.sync(), MCPError, "did not announce");
} finally {
await client.dispose();
try {
open?.close();
} catch {
// already errored by the aborted fetch
}
await server.shutdown();
}
});
Deno.test("failed legacy POST does not leave a process-killing orphan", async () => {
const enc = new TextEncoder();
const fetchStub = ((input: RequestInfo | URL, init?: RequestInit) => {
void input;
if (!init?.method || init.method === "GET") {
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(
enc.encode("event: endpoint\ndata: /messages\n\n"),
);
init?.signal?.addEventListener("abort", () => {
try {
controller.error(new DOMException("aborted", "AbortError"));
} catch {
// already closed
}
});
},
});
return Promise.resolve(
new Response(body, {
headers: { "Content-Type": "text/event-stream" },
}),
);
}
return Promise.reject(new TypeError("connection reset"));
}) as typeof fetch;
const client = new MCPClient(
MCPClientConfigSchema.parse({
id: "flaky",
url: "http://mcp.test/",
transport: "http-sse",
requestTimeoutMs: 100,
}),
fetchStub,
);
try {
await assertRejects(() => client.sync(), TypeError);
// The orphaned pending timer would reject a promise nobody awaits after
// requestTimeoutMs; surviving 3x that window proves the cleanup.
await new Promise((r) => setTimeout(r, 300));
} finally {
await client.dispose();
}
});