140 lines
4.3 KiB
TypeScript
Executable File
140 lines
4.3 KiB
TypeScript
Executable File
import type { Router } from "../../../packages/core/src/mod.ts";
|
|
import type { AppContext } from "../context.ts";
|
|
|
|
interface JsonRpcRequest {
|
|
jsonrpc?: string;
|
|
id?: number | string;
|
|
method?: string;
|
|
params?: Record<string, unknown>;
|
|
}
|
|
|
|
function rpcError(
|
|
id: number | string | null,
|
|
code: number,
|
|
message: string,
|
|
): Response {
|
|
return Response.json({ jsonrpc: "2.0", id, error: { code, message } });
|
|
}
|
|
|
|
function rpcResult(id: number | string, result: unknown): Response {
|
|
return Response.json({ jsonrpc: "2.0", id, result });
|
|
}
|
|
|
|
export function registerMCPServerRoutes(router: Router, ctx: AppContext): void {
|
|
router.post("/mcp", async (req) => {
|
|
let body: JsonRpcRequest;
|
|
try {
|
|
const parsed = await req.json() as unknown;
|
|
if (Array.isArray(parsed)) {
|
|
// 2025-06-18 removed batching; acknowledging a batch as a
|
|
// notification would silently swallow real requests.
|
|
return rpcError(
|
|
null,
|
|
-32600,
|
|
"Batching is not supported (protocol 2025-06-18); send one " +
|
|
"JSON-RPC object per request.",
|
|
);
|
|
}
|
|
if (typeof parsed !== "object" || parsed === null) {
|
|
return rpcError(null, -32600, "Invalid Request");
|
|
}
|
|
body = parsed as JsonRpcRequest;
|
|
} catch {
|
|
return rpcError(null, -32700, "Parse error");
|
|
}
|
|
ctx.metrics.increment("requests.mcp_server");
|
|
|
|
// Notifications get acknowledged without a body.
|
|
if (body.id === undefined) {
|
|
return new Response(null, { status: 202 });
|
|
}
|
|
|
|
switch (body.method) {
|
|
case "initialize":
|
|
return rpcResult(body.id, {
|
|
protocolVersion: "2025-06-18",
|
|
capabilities: { tools: {} },
|
|
serverInfo: { name: "frosty-gateway", version: ctx.version },
|
|
});
|
|
|
|
case "tools/list":
|
|
return rpcResult(body.id, {
|
|
tools: ctx.mcp.toolCatalog().map((tool) => ({
|
|
name: tool.name,
|
|
description: tool.description,
|
|
inputSchema: tool.inputSchema ?? { type: "object" },
|
|
annotations: tool.annotations,
|
|
})),
|
|
});
|
|
|
|
case "tools/call": {
|
|
const name = String(body.params?.name ?? "");
|
|
// Atomic bind: catalogs re-sync concurrently, so the side-effect
|
|
// gate and the execution must observe ONE tool instance (TOCTOU).
|
|
const resolved = ctx.toolExecutor.resolve?.(name) ??
|
|
(ctx.toolExecutor.has(name)
|
|
? {
|
|
isSideEffect: ctx.toolExecutor.isSideEffect(name),
|
|
execute: (args: unknown) => ctx.toolExecutor.execute(name, args),
|
|
}
|
|
: undefined);
|
|
if (!resolved) {
|
|
return rpcError(body.id, -32602, `Unknown tool "${name}".`);
|
|
}
|
|
// Same side-effect posture as the inference tool loop: fail closed.
|
|
const confirmed =
|
|
req.headers.get("x-frosty-confirm-side-effects") === "true";
|
|
if (resolved.isSideEffect && !confirmed) {
|
|
return rpcError(
|
|
body.id,
|
|
-32000,
|
|
`Tool "${name}" may have side effects; repeat the call with the ` +
|
|
`x-frosty-confirm-side-effects: true header to allow it.`,
|
|
);
|
|
}
|
|
try {
|
|
const text = await resolved.execute(body.params?.arguments ?? {});
|
|
return rpcResult(body.id, {
|
|
content: [{ type: "text", text }],
|
|
isError: false,
|
|
});
|
|
} catch (error) {
|
|
return rpcResult(body.id, {
|
|
content: [{
|
|
type: "text",
|
|
text: error instanceof Error ? error.message : String(error),
|
|
}],
|
|
isError: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
default:
|
|
return rpcError(
|
|
body.id,
|
|
-32601,
|
|
`Method "${body.method}" not found.`,
|
|
);
|
|
}
|
|
});
|
|
|
|
router.get("/mcp", () => {
|
|
ctx.metrics.increment("requests.mcp_server");
|
|
const encoder = new TextEncoder();
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(next) {
|
|
next.enqueue(encoder.encode(
|
|
'data: {"jsonrpc":"2.0","method":"connection/opened"}\n\n',
|
|
));
|
|
},
|
|
});
|
|
return new Response(stream, {
|
|
headers: {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
"Connection": "keep-alive",
|
|
},
|
|
});
|
|
});
|
|
}
|