Files
GearBox/src/server/mcp/index.ts
Jean-Luc Makiola df6c75f164 feat(25-02): add MCP catalog tools upsert_catalog_item and bulk_upsert_catalog
- New catalog.ts with catalogToolDefinitions and registerCatalogTools
- upsert_catalog_item: single item upsert with full attribution fields (SEED-03)
- bulk_upsert_catalog: batch upsert up to 100 items with created/updated counts
- Registered in createMcpServer after image tools
- 6 new MCP catalog tool tests passing
2026-04-10 11:03:50 +02:00

208 lines
6.3 KiB
TypeScript

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);
});