mcp-server.ts83 lines · main
1import { StreamableHTTPTransport } from '@hono/mcp';
2import { Hono } from 'hono';
3
4import { hashIp } from '../services/audit.js';
5import { verifyMcpKey } from '../services/mcp-access.js';
6import { buildMcpServer } from '../services/mcp-tools.js';
7
8/**
9 * mcp.briven.tech — the live MCP server endpoint, mounted INSIDE the existing
10 * api (not a new app/container). An AI agent connects here over the MCP
11 * Streamable HTTP transport, presents a per-project key as
12 * `Authorization: Bearer pk_briven_mcp_…`, and is HARD-LOCKED to exactly that
13 * one project's DoltGres data-plane database for the life of the request.
14 *
15 * Transport choice: the api runs on Bun and composes routes with Hono's Fetch
16 * model (Request/Response), whereas the SDK's own `StreamableHTTPServerTransport`
17 * is built around Node's `http` `IncomingMessage`/`ServerResponse`. `@hono/mcp`'s
18 * `StreamableHTTPTransport` is the SDK-compatible Streamable HTTP transport that
19 * speaks Hono's `Context` directly — so it drops into this Bun/Hono app with no
20 * Node-http shim. It is run STATELESS (`sessionIdGenerator: undefined`): a fresh
21 * server + transport is built per request, bound to the freshly-verified key, so
22 * a binding can never leak across keys or sessions. `enableJsonResponse` returns
23 * a plain JSON-RPC response (no SSE) for simple request/response clients.
24 *
25 * CSRF: the global `csrfOriginCheck` middleware has a Bearer-token carve-out, so
26 * a server-to-server MCP POST carrying `Authorization: Bearer …` is never gated
27 * by the browser-origin check.
28 */
29export const mcpServerRouter = new Hono();
30
31const BEARER_RE = /^Bearer\s+(.+)$/i;
32
33// Streamable HTTP clients open a GET /mcp to listen for server->client SSE
34// messages, and may send DELETE /mcp to end a session. This server is STATELESS
35// (`sessionIdGenerator: undefined`) and answers every POST inline as JSON, so it
36// offers no server->client stream and holds no session to terminate. Per the MCP
37// Streamable HTTP spec the correct answer is an immediate 405 Method Not Allowed.
38// Previously these fell through the `.all` handler into the transport, where the
39// GET path hung ~10s and then 500'd — which stalled the client's connection until
40// it timed out and got benched. Answering 405 instantly keeps the door responsive.
41mcpServerRouter.on(['GET', 'DELETE'], '/mcp', (c) =>
42 c.json(
43 { jsonrpc: '2.0', error: { code: -32000, message: 'Method Not Allowed' }, id: null },
44 405,
45 { Allow: 'POST' },
46 ),
47);
48
49mcpServerRouter.post('/mcp', async (c) => {
50 // 1. Extract the presented key from the Authorization header.
51 const authz = c.req.header('authorization') ?? '';
52 const presented = BEARER_RE.exec(authz)?.[1]?.trim() ?? null;
53
54 // 2. Verify it + resolve the project binding. 401 = bad/missing/revoked key;
55 // 403 = valid key but a plan/enablement gate refused it. The plaintext key
56 // is never logged.
57 const verified = await verifyMcpKey(presented);
58 if (!verified.ok) {
59 return c.json({ error: 'mcp_access_denied', reason: verified.reason }, verified.status);
60 }
61
62 // 3. Build a server bound to THIS key's project + scope and let the transport
63 // drive the JSON-RPC exchange. Each tool derives the project id from this
64 // binding only — never from the request payload.
65 const ipHash = hashIp(
66 c.req.raw.headers.get('cf-connecting-ip') ?? c.req.raw.headers.get('x-forwarded-for'),
67 );
68 const server = buildMcpServer({
69 keyId: verified.keyId,
70 projectId: verified.projectId,
71 scope: verified.scope,
72 ipHash,
73 userAgent: c.req.header('user-agent') ?? null,
74 });
75
76 const transport = new StreamableHTTPTransport({
77 sessionIdGenerator: undefined,
78 enableJsonResponse: true,
79 });
80 await server.connect(transport);
81 const res = await transport.handleRequest(c);
82 return res ?? c.body(null, 204);
83});