From 6f51432d426c46a83ecb0e22e27b7a7e226ab52e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 3 Apr 2026 13:38:18 +0200 Subject: [PATCH] feat: add MCP server with streamable HTTP transport at /mcp Co-Authored-By: Claude Opus 4.6 (1M context) --- src/server/index.ts | 6 ++ src/server/mcp/index.ts | 171 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 src/server/mcp/index.ts diff --git a/src/server/index.ts b/src/server/index.ts index fcaf751..54789ea 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,6 +1,7 @@ import { Hono } from "hono"; import { serveStatic } from "hono/bun"; import { seedDefaults } from "../db/seed.ts"; +import { mcpRoutes } from "./mcp/index.ts"; import { requireAuth } from "./middleware/auth.ts"; import { authRoutes } from "./routes/auth.ts"; import { categoryRoutes } from "./routes/categories.ts"; @@ -41,6 +42,11 @@ app.route("/api/settings", settingsRoutes); app.route("/api/threads", threadRoutes); app.route("/api/setups", setupRoutes); +// MCP server (conditionally mounted) +if (process.env.GEARBOX_MCP !== "false") { + app.route("/mcp", mcpRoutes); +} + // Serve uploaded images app.use("/uploads/*", serveStatic({ root: "./" })); diff --git a/src/server/mcp/index.ts b/src/server/mcp/index.ts new file mode 100644 index 0000000..da0ebda --- /dev/null +++ b/src/server/mcp/index.ts @@ -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(); + +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); +});