hermes/backend/mcp/index.ts
Nico ccee249618 v0.6.42: Hermes chat UI — Vue3/TS/Vite, audio STT/TTS, sidebar rail, MCP event loop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:35:10 +02:00

148 lines
5.7 KiB
TypeScript

/**
* mcp/index.ts — MCP server setup for Hermes backend
*
* Auth: every request must include Authorization: Bearer <mcp-key>
* 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<string, McpKeyEntry> {
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<string, McpKeyEntry>) {
const obj: Record<string, McpKeyEntry> = {};
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<Response> {
// 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);
}