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:
@@ -1,6 +1,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { serveStatic } from "hono/bun";
|
import { serveStatic } from "hono/bun";
|
||||||
import { seedDefaults } from "../db/seed.ts";
|
import { seedDefaults } from "../db/seed.ts";
|
||||||
|
import { mcpRoutes } from "./mcp/index.ts";
|
||||||
import { requireAuth } from "./middleware/auth.ts";
|
import { requireAuth } from "./middleware/auth.ts";
|
||||||
import { authRoutes } from "./routes/auth.ts";
|
import { authRoutes } from "./routes/auth.ts";
|
||||||
import { categoryRoutes } from "./routes/categories.ts";
|
import { categoryRoutes } from "./routes/categories.ts";
|
||||||
@@ -41,6 +42,11 @@ app.route("/api/settings", settingsRoutes);
|
|||||||
app.route("/api/threads", threadRoutes);
|
app.route("/api/threads", threadRoutes);
|
||||||
app.route("/api/setups", setupRoutes);
|
app.route("/api/setups", setupRoutes);
|
||||||
|
|
||||||
|
// MCP server (conditionally mounted)
|
||||||
|
if (process.env.GEARBOX_MCP !== "false") {
|
||||||
|
app.route("/mcp", mcpRoutes);
|
||||||
|
}
|
||||||
|
|
||||||
// Serve uploaded images
|
// Serve uploaded images
|
||||||
app.use("/uploads/*", serveStatic({ root: "./" }));
|
app.use("/uploads/*", serveStatic({ root: "./" }));
|
||||||
|
|
||||||
|
|||||||
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