289 lines
8.8 KiB
TypeScript
Executable File
289 lines
8.8 KiB
TypeScript
Executable File
// Wave-3 MCP: stdio transport (subprocess JSON-RPC), the gateway exposed AS
|
|
// an MCP server (with the fail-closed side-effect gate), and health checks.
|
|
|
|
import { assert, assertEquals, assertRejects } 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_SERVER = `
|
|
const dec = new TextDecoder();
|
|
const enc = new TextEncoder();
|
|
let buf = "";
|
|
for await (const chunk of Deno.stdin.readable) {
|
|
buf += dec.decode(chunk);
|
|
let i;
|
|
while ((i = buf.indexOf("\\n")) >= 0) {
|
|
const line = buf.slice(0, i);
|
|
buf = buf.slice(i + 1);
|
|
if (!line.trim()) continue;
|
|
const msg = JSON.parse(line);
|
|
if (msg.id === undefined) continue;
|
|
let result;
|
|
if (msg.method === "initialize") {
|
|
result = { protocolVersion: "2025-06-18" };
|
|
} else if (msg.method === "tools/list") {
|
|
result = {
|
|
tools: [{
|
|
name: "stdio-echo",
|
|
description: "echoes over stdio",
|
|
annotations: { readOnlyHint: true },
|
|
}],
|
|
};
|
|
} else if (msg.method === "tools/call") {
|
|
result = { content: [{ type: "text", text: "from-stdio" }] };
|
|
} else {
|
|
result = {};
|
|
}
|
|
await Deno.stdout.write(
|
|
enc.encode(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result }) + "\\n"),
|
|
);
|
|
}
|
|
}
|
|
`;
|
|
|
|
Deno.test("stdio transport: subprocess JSON-RPC round-trip + dispose", async () => {
|
|
const script = await Deno.makeTempFile({ suffix: ".ts" });
|
|
await Deno.writeTextFile(script, STDIO_SERVER);
|
|
const client = new MCPClient(MCPClientConfigSchema.parse({
|
|
id: "stdio-t",
|
|
transport: "stdio",
|
|
command: [Deno.execPath(), "run", "--quiet", script],
|
|
}));
|
|
try {
|
|
const tools = await client.sync();
|
|
assertEquals(tools.map((t) => t.name), ["stdio-echo"]);
|
|
assertEquals(await client.callTool("stdio-echo", { a: 1 }), "from-stdio");
|
|
assertEquals(client.resolvedTransport, "stdio");
|
|
} finally {
|
|
await client.dispose();
|
|
await Deno.remove(script);
|
|
}
|
|
});
|
|
|
|
Deno.test("stdio config validation: command required, url not", () => {
|
|
const bad = MCPClientConfigSchema.safeParse({
|
|
id: "x",
|
|
transport: "stdio",
|
|
});
|
|
assert(!bad.success);
|
|
const badHttp = MCPClientConfigSchema.safeParse({ id: "y" });
|
|
assert(!badHttp.success); // http transports (default http-sse) need a url
|
|
});
|
|
|
|
interface RpcRequest {
|
|
id?: number;
|
|
method: string;
|
|
params?: { name?: string };
|
|
}
|
|
|
|
/** Upstream MCP double with one read-only and one side-effecting tool. */
|
|
function upstreamMCP() {
|
|
const server = Deno.serve({ port: 0, onListen: () => {} }, async (req) => {
|
|
const body = await req.json() as RpcRequest;
|
|
if (body.id === undefined) {
|
|
return new Response(null, { status: 202 });
|
|
}
|
|
if (body.method === "initialize") {
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id: body.id,
|
|
result: { protocolVersion: "2025-06-18" },
|
|
});
|
|
}
|
|
if (body.method === "tools/list") {
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id: body.id,
|
|
result: {
|
|
tools: [
|
|
{ name: "lookup", annotations: { readOnlyHint: true } },
|
|
{ name: "write-db" },
|
|
],
|
|
},
|
|
});
|
|
}
|
|
return Response.json({
|
|
jsonrpc: "2.0",
|
|
id: body.id,
|
|
result: {
|
|
content: [{ type: "text", text: `ran:${body.params?.name}` }],
|
|
},
|
|
});
|
|
});
|
|
return {
|
|
url: `http://127.0.0.1:${(server.addr as Deno.NetAddr).port}`,
|
|
shutdown: () => server.shutdown(),
|
|
};
|
|
}
|
|
|
|
function serverContext(mcp: MCPRegistry): AppContext {
|
|
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("gateway as MCP server: list/call with side-effect gate", async () => {
|
|
const upstream = upstreamMCP();
|
|
const mcp = new MCPRegistry([
|
|
{ id: "up", url: upstream.url, transport: "streamable-http" },
|
|
]);
|
|
await mcp.syncAll();
|
|
const gateway = Deno.serve(
|
|
{ port: 0, onListen: () => {} },
|
|
createHandler(serverContext(mcp)),
|
|
);
|
|
const gatewayUrl = `http://127.0.0.1:${
|
|
(gateway.addr as Deno.NetAddr).port
|
|
}/mcp`;
|
|
|
|
const plain = new MCPClient(MCPClientConfigSchema.parse({
|
|
id: "host",
|
|
url: gatewayUrl,
|
|
transport: "streamable-http",
|
|
}));
|
|
const confirming = new MCPClient(MCPClientConfigSchema.parse({
|
|
id: "host2",
|
|
url: gatewayUrl,
|
|
transport: "streamable-http",
|
|
headers: { "x-frosty-confirm-side-effects": "true" },
|
|
}));
|
|
try {
|
|
// Our own MCP client speaks to the gateway's server surface.
|
|
const tools = await plain.sync();
|
|
assertEquals(tools.map((t) => t.name).sort(), ["lookup", "write-db"]);
|
|
|
|
// Read-only tools execute freely and proxy through to the upstream.
|
|
assertEquals(await plain.callTool("lookup", {}), "ran:lookup");
|
|
|
|
// Side-effecting tools fail closed without the confirmation header…
|
|
await assertRejects(
|
|
() => plain.callTool("write-db", {}),
|
|
MCPError,
|
|
"side effects",
|
|
);
|
|
|
|
// …and run when the host explicitly confirms.
|
|
await confirming.sync();
|
|
assertEquals(await confirming.callTool("write-db", {}), "ran:write-db");
|
|
|
|
// Unknown methods are proper JSON-RPC errors.
|
|
await assertRejects(
|
|
() => plain.callTool("no-such-tool", {}),
|
|
MCPError,
|
|
"Unknown tool",
|
|
);
|
|
} finally {
|
|
await plain.dispose();
|
|
await confirming.dispose();
|
|
await gateway.shutdown();
|
|
await upstream.shutdown();
|
|
}
|
|
});
|
|
|
|
Deno.test("gateway MCP server: GET opens a governed SSE channel", async () => {
|
|
const ctx = serverContext(new MCPRegistry());
|
|
ctx.virtualKeys.upsert({
|
|
id: "mcp-sse-key",
|
|
name: "mcp-sse",
|
|
token: "vk-mcp-sse-test-token",
|
|
enabled: true,
|
|
usedRequests: 0,
|
|
usedCostMicroUsd: 0,
|
|
});
|
|
const handler = createHandler(ctx);
|
|
|
|
const denied = await handler(new Request("http://gateway.test/mcp"));
|
|
assertEquals(denied.status, 401);
|
|
|
|
const opened = await handler(
|
|
new Request("http://gateway.test/mcp", {
|
|
headers: { Authorization: "Bearer vk-mcp-sse-test-token" },
|
|
}),
|
|
);
|
|
assertEquals(opened.status, 200);
|
|
assertEquals(opened.headers.get("content-type"), "text/event-stream");
|
|
assertEquals(opened.headers.get("cache-control"), "no-cache");
|
|
assertEquals(opened.headers.get("connection"), "keep-alive");
|
|
const reader = opened.body!.getReader();
|
|
const first = await reader.read();
|
|
assertEquals(first.done, false);
|
|
assertEquals(
|
|
new TextDecoder().decode(first.value),
|
|
'data: {"jsonrpc":"2.0","method":"connection/opened"}\n\n',
|
|
);
|
|
await reader.cancel();
|
|
});
|
|
|
|
Deno.test("gateway MCP server: SSE remains open over real HTTP", async () => {
|
|
const gateway = Deno.serve(
|
|
{ port: 0, onListen: () => {} },
|
|
createHandler(serverContext(new MCPRegistry())),
|
|
);
|
|
const url = `http://127.0.0.1:${(gateway.addr as Deno.NetAddr).port}/mcp`;
|
|
try {
|
|
const response = await fetch(url);
|
|
const reader = response.body!.getReader();
|
|
const first = await reader.read();
|
|
assertEquals(first.done, false);
|
|
const pending = reader.read();
|
|
const stillOpen = await Promise.race([
|
|
pending.then(() => false),
|
|
new Promise<true>((resolve) => setTimeout(() => resolve(true), 25)),
|
|
]);
|
|
assertEquals(stillOpen, true);
|
|
await reader.cancel();
|
|
} finally {
|
|
await gateway.shutdown();
|
|
}
|
|
});
|
|
|
|
Deno.test("/api/mcp/health reports healthy and unhealthy servers", async () => {
|
|
const upstream = upstreamMCP();
|
|
const mcp = new MCPRegistry([
|
|
{ id: "good", url: upstream.url, transport: "streamable-http" },
|
|
{
|
|
id: "dead",
|
|
url: "http://127.0.0.1:9",
|
|
transport: "streamable-http",
|
|
requestTimeoutMs: 500,
|
|
},
|
|
]);
|
|
const handler = createHandler(serverContext(mcp));
|
|
try {
|
|
const res = await handler(
|
|
new Request("http://gateway.test/api/mcp/health"),
|
|
);
|
|
assertEquals(res.status, 200);
|
|
const body = await res.json() as {
|
|
health: Array<{ clientId: string; status: string }>;
|
|
};
|
|
const byId = Object.fromEntries(
|
|
body.health.map((h) => [h.clientId, h.status]),
|
|
);
|
|
assertEquals(byId["good"], "healthy");
|
|
assertEquals(byId["dead"], "unhealthy");
|
|
} finally {
|
|
await upstream.shutdown();
|
|
}
|
|
});
|