148 lines
5.7 KiB
TypeScript
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);
|
|
}
|