/** * mcp/index.ts — MCP server setup for Hermes backend * * Auth: every request must include Authorization: Bearer * Each MCP key is linked to a takeover token on the backend. * Takeover tools use the linked token implicitly — no token param needed. */ import * as fs from "fs"; import * as path from "path"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { tools as takeoverTools, handle as handleTakeover, setBridge, setActiveToken, type TakeoverBridge } from "./takeover.ts"; import { tools as deckTools, handle as handleDeck } from "./deck.ts"; import { tools as devTools, handle as handleDev } from "./dev.ts"; import { tools as systemTools, handle as handleSystem } from "./system.ts"; import { tools as fsTools, handle as handleFs } from "./fs.ts"; import { setActiveMcpKey } from "./events.ts"; const allTools = [...takeoverTools, ...deckTools, ...devTools, ...systemTools, ...fsTools]; // ── MCP API key → takeover token mapping ── interface McpKeyEntry { takeoverToken: string; createdAt: number; user?: string; } const MCP_KEYS_FILE = path.join(import.meta.dir, "..", ".mcp-keys.json"); function loadMcpKeys(): Map { try { const data = JSON.parse(fs.readFileSync(MCP_KEYS_FILE, "utf8")); return new Map(Object.entries(data)); } catch { return new Map(); } } function saveMcpKeys(keys: Map) { const obj: Record = {}; for (const [k, v] of keys) obj[k] = v; fs.writeFileSync(MCP_KEYS_FILE, JSON.stringify(obj, null, 2)); } export function registerMcpKey(mcpKey: string, takeoverToken: string, user?: string) { const keys = loadMcpKeys(); keys.set(mcpKey, { takeoverToken, createdAt: Date.now(), user }); saveMcpKeys(keys); console.log(`[mcp] Key registered: ${mcpKey.slice(0, 8)}... → takeover ${takeoverToken.slice(0, 8)}...`); } /** Called when browser re-enables takeover — auto-updates MCP key for this user */ export function refreshMcpKeyForUser(user: string, newTakeoverToken: string) { const keys = loadMcpKeys(); let updated = false; for (const [k, v] of keys) { // Match by user field, or update unowned keys if only one key exists (legacy/single-user) if (v.user === user || (!v.user && keys.size === 1)) { keys.set(k, { ...v, takeoverToken: newTakeoverToken, user }); updated = true; } } if (updated) { saveMcpKeys(keys); console.log(`[mcp] Auto-updated key for ${user} → takeover ${newTakeoverToken.slice(0, 8)}...`); } } function validateAuth(req: Request): { entry: McpKeyEntry; mcpKey: string } | null { const auth = req.headers.get("authorization") || ""; const match = auth.match(/^Bearer\s+(.+)$/i); if (!match) return null; const keys = loadMcpKeys(); const entry = keys.get(match[1]); return entry ? { entry, mcpKey: match[1] } : null; } // ── MCP Server factory ── let bridgeRef: TakeoverBridge | null = null; function createSessionServer(): { server: Server; transport: WebStandardStreamableHTTPServerTransport } { const server = new Server( { name: "hermes-mcp", version: "1.0.0" }, { capabilities: { tools: {} } }, ); server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: allTools }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; if (name.startsWith("takeover_")) return handleTakeover(name, args); if (name.startsWith("deck_")) return handleDeck(name, args); if (name.startsWith("dev_")) return handleDev(name, args); if (name.startsWith("system_")) return handleSystem(name, args); if (name.startsWith("fs_")) return handleFs(name, args); throw new Error(`Unknown tool: ${name}`); }); const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true, }); server.connect(transport); return { server, transport }; } export function setupMcp(bridge: TakeoverBridge) { bridgeRef = bridge; setBridge(bridge); console.log(` MCP: /mcp (${allTools.length} tools, ${loadMcpKeys().size} key(s))`); } /** Handle an incoming /mcp request — called from server.ts fetch handler */ export async function handleMcpRequest(req: Request): Promise { // Auth gate — require valid MCP API key const auth = validateAuth(req); if (!auth) { return new Response(JSON.stringify({ error: "Unauthorized — Bearer token required" }), { status: 401, headers: { "Content-Type": "application/json" }, }); } // Set the active takeover token for this request setActiveToken(auth.entry.takeoverToken); setActiveMcpKey(auth.mcpKey); // Ensure Accept header includes text/event-stream (Cloudflare/proxies may strip it) const accept = req.headers.get("accept") || ""; if (!accept.includes("text/event-stream")) { const headers = new Headers(req.headers); headers.set("accept", "application/json, text/event-stream"); req = new Request(req.url, { method: req.method, headers, body: req.body, // @ts-ignore — Bun supports duplex duplex: "half", }); } // Stateless: fresh server+transport per request, survives hot reloads const { transport } = createSessionServer(); return transport.handleRequest(req); }