feat: add MCP server with streamable HTTP transport at /mcp

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-03 13:38:18 +02:00
parent 8919829167
commit 6f51432d42
2 changed files with 177 additions and 0 deletions

171
src/server/mcp/index.ts Normal file
View File

@@ -0,0 +1,171 @@
import { randomUUID } from "node:crypto";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { Hono } from "hono";
import { db as prodDb } from "@/db/index.ts";
import { verifyApiKey } from "../services/auth.service.ts";
import { getCollectionSummary } from "./resources/collection.ts";
import {
categoryToolDefinitions,
registerCategoryTools,
} from "./tools/categories.ts";
import { imageToolDefinitions, registerImageTools } from "./tools/images.ts";
import { itemToolDefinitions, registerItemTools } from "./tools/items.ts";
import { registerSetupTools, setupToolDefinitions } from "./tools/setups.ts";
import { registerThreadTools, threadToolDefinitions } from "./tools/threads.ts";
type Db = typeof prodDb;
function createMcpServer(db: Db): McpServer {
const server = new McpServer({ name: "GearBox", version: "1.0.0" });
// Register item tools
const itemHandlers = registerItemTools(db);
for (const def of itemToolDefinitions) {
const handler = itemHandlers[def.name as keyof typeof itemHandlers];
server.tool(def.name, def.description, def.inputSchema, handler);
}
// Register category tools
const categoryHandlers = registerCategoryTools(db);
for (const def of categoryToolDefinitions) {
const handler = categoryHandlers[def.name as keyof typeof categoryHandlers];
server.tool(def.name, def.description, def.inputSchema, handler);
}
// Register thread tools
const threadHandlers = registerThreadTools(db);
for (const def of threadToolDefinitions) {
const handler = threadHandlers[def.name as keyof typeof threadHandlers];
server.tool(def.name, def.description, def.inputSchema, handler);
}
// Register setup tools
const setupHandlers = registerSetupTools(db);
for (const def of setupToolDefinitions) {
const handler = setupHandlers[def.name as keyof typeof setupHandlers];
server.tool(def.name, def.description, def.inputSchema, handler);
}
// Register image tools
const imageHandlers = registerImageTools();
for (const def of imageToolDefinitions) {
const handler = imageHandlers[def.name as keyof typeof imageHandlers];
server.tool(def.name, def.description, def.inputSchema, handler);
}
// Register collection summary resource
server.resource(
"collection-summary",
"gearbox://collection/summary",
{
description:
"Overview of the entire gear collection including totals, categories, and active research threads.",
mimeType: "application/json",
},
async () => {
const summary = getCollectionSummary(db);
return {
contents: [
{
uri: "gearbox://collection/summary",
mimeType: "application/json",
text: JSON.stringify(summary, null, 2),
},
],
};
},
);
return server;
}
// Store active transports by session ID
const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
export const mcpRoutes = new Hono();
// Auth middleware for all MCP requests
mcpRoutes.use("/*", async (c, next) => {
const db = c.get("db") ?? prodDb;
const apiKey = c.req.header("X-API-Key");
if (apiKey) {
const valid = await verifyApiKey(db, apiKey);
if (!valid) {
return c.json({ error: "Invalid API key" }, 401);
}
}
// If no API key header, allow through (unauthenticated access when auth not configured)
return next();
});
mcpRoutes.post("/", async (c) => {
const db = c.get("db") ?? prodDb;
// Check for existing session
const sessionId = c.req.header("mcp-session-id");
if (sessionId) {
const transport = transports.get(sessionId);
if (!transport) {
return c.json({ error: "Session not found" }, 404);
}
const response = await transport.handleRequest(c.req.raw);
return response;
}
// New session: create transport and MCP server
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (newSessionId) => {
transports.set(newSessionId, transport);
},
});
// Clean up on close
transport.onclose = () => {
const sid = [...transports.entries()].find(
([_, t]) => t === transport,
)?.[0];
if (sid) transports.delete(sid);
};
const server = createMcpServer(db);
await server.connect(transport);
const response = await transport.handleRequest(c.req.raw);
return response;
});
mcpRoutes.get("/", async (c) => {
const sessionId = c.req.header("mcp-session-id");
if (!sessionId) {
return c.json({ error: "Session ID required" }, 400);
}
const transport = transports.get(sessionId);
if (!transport) {
return c.json({ error: "Session not found" }, 404);
}
const response = await transport.handleRequest(c.req.raw);
return response;
});
mcpRoutes.delete("/", async (c) => {
const sessionId = c.req.header("mcp-session-id");
if (!sessionId) {
return c.json({ error: "Session ID required" }, 400);
}
const transport = transports.get(sessionId);
if (!transport) {
return c.json({ error: "Session not found" }, 404);
}
await transport.close();
transports.delete(sessionId);
return c.text("", 200);
});