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 { verifyAccessToken } from "../services/oauth.service.ts"; import { getCollectionSummary } from "./resources/collection.ts"; import { catalogToolDefinitions, registerCatalogTools, } from "./tools/catalog.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, userId: number): McpServer { const server = new McpServer({ name: "GearBox", version: "1.0.0" }); // Register item tools const itemHandlers = registerItemTools(db, userId); 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, userId); 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, userId); 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, userId); 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 catalog tools (no userId needed — catalog is global) const catalogHandlers = registerCatalogTools(db); for (const def of catalogToolDefinitions) { const handler = catalogHandlers[def.name as keyof typeof catalogHandlers]; 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 = await getCollectionSummary(db, userId); return { contents: [ { uri: "gearbox://collection/summary", mimeType: "application/json", text: JSON.stringify(summary, null, 2), }, ], }; }, ); return server; } // Store active transports by session ID (with userId for session reuse) const transports = new Map< string, { transport: WebStandardStreamableHTTPServerTransport; userId: number } >(); export const mcpRoutes = new Hono(); // Auth middleware for all MCP requests — resolves userId mcpRoutes.use("/*", async (c, next) => { const db = c.get("db") ?? prodDb; // Try Bearer token first (OAuth) const authHeader = c.req.header("Authorization"); if (authHeader?.startsWith("Bearer ")) { const token = authHeader.slice(7); const result = await verifyAccessToken(db, token); if (result) { c.set("userId", result.userId); return next(); } return c.json({ error: "invalid_token" }, 401); } // Try API key const apiKey = c.req.header("X-API-Key"); if (apiKey) { const result = await verifyApiKey(db, apiKey); if (result) { c.set("userId", result.userId); return next(); } return c.json({ error: "Invalid API key" }, 401); } // No auth provided — return 401 with WWW-Authenticate to trigger OAuth flow (RFC 9728) const baseUrl = ( process.env.GEARBOX_URL || new URL(c.req.url).origin ).replace(/\/$/, ""); return c.text("Unauthorized", 401, { "WWW-Authenticate": `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"`, }); }); mcpRoutes.post("/", async (c) => { const db = c.get("db") ?? prodDb; const userId = c.get("userId") as number; // Check for existing session const sessionId = c.req.header("mcp-session-id"); if (sessionId) { const session = transports.get(sessionId); if (!session) { return c.json({ error: "Session not found" }, 404); } const response = await session.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, userId }); }, }); // Clean up on close transport.onclose = () => { const sid = [...transports.entries()].find( ([_, s]) => s.transport === transport, )?.[0]; if (sid) transports.delete(sid); }; const server = createMcpServer(db, userId); 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 session = transports.get(sessionId); if (!session) { return c.json({ error: "Session not found" }, 404); } const response = await session.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 session = transports.get(sessionId); if (!session) { return c.json({ error: "Session not found" }, 404); } await session.transport.close(); transports.delete(sessionId); return c.text("", 200); });