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:
171
src/server/mcp/index.ts
Normal file
171
src/server/mcp/index.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user