From c154f421dbf04c7de7ce1817173df6e49dc4ab14 Mon Sep 17 00:00:00 2001 From: Aakash Kattelu Date: Mon, 31 Aug 2026 15:50:45 -0400 Subject: [PATCH] fix(mcp): stdio launcher cwd/silent and HTTP session bounds Pin bun --cwd so bunfig loads. Silence bun run. Require Bearer on HTTP. Idle-expire and cap in-memory MCP sessions. --- mcp/README.md | 15 ++++++------ mcp/bunfig.toml | 3 +++ mcp/src/http.ts | 64 +++++++++++++++++++++++++++++++++---------------- 3 files changed, 53 insertions(+), 29 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index 59fe1e1c..fcbad17c 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -93,13 +93,13 @@ bunx mcp-remote http://127.0.0.1:3000 \ --header "Authorization:Bearer " ``` -Auth is the `Authorization: Bearer` header (same as the Worker). If that header -is omitted, `HONCHO_API_KEY` in the environment is used. Optional -`HONCHO_WORKSPACE_ID` or `X-Honcho-Workspace-ID` fills `workspace_id` when the -tool argument is omitted. +Auth is the `Authorization: Bearer` header (same as the Worker). Optional +`X-Honcho-Workspace-ID` fills `workspace_id` when the tool argument is omitted. `HOST` defaults to `0.0.0.0`, `PORT` to `3000`. `GET /health` is unauthenticated. -MCP is served at `/` and `/mcp`. +MCP is served at `/` and `/mcp`. Idle sessions expire after +`MCP_SESSION_IDLE_MS` (default 30 minutes); `MCP_SESSION_MAX` (default 128) +caps concurrent sessions. A platform start command is `bun src/http.ts` (or `bun run http` from `mcp/`). This repo does not ship a `vercel.json`; serverless replicas do not share the @@ -120,8 +120,7 @@ docker run --rm -p 3000:3000 \ ## Local stdio For a local Honcho instance, or any MCP client that spawns a process, run the -stdio host instead of the Worker. Point the client at `src/stdio.ts` directly -— `bun run stdio` writes lifecycle output to stdout and breaks the protocol. +stdio host. `--cwd` loads `mcp/bunfig.toml` (Markdown loader) from this package. ```bash cd mcp && bun install @@ -130,7 +129,7 @@ claude mcp add honcho -- \ -e HONCHO_API_KEY=hch-your-key-here \ -e HONCHO_API_URL=http://127.0.0.1:28000 \ -e HONCHO_WORKSPACE_ID=my-workspace \ - bun "$(pwd)/src/stdio.ts" + bun --cwd "$(pwd)" src/stdio.ts ``` `HONCHO_API_URL` defaults to `https://api.honcho.dev`. `HONCHO_WORKSPACE_ID` is diff --git a/mcp/bunfig.toml b/mcp/bunfig.toml index 5c44f0c9..9d1af97a 100644 --- a/mcp/bunfig.toml +++ b/mcp/bunfig.toml @@ -1,2 +1,5 @@ [loader] ".md" = "text" + +[run] +silent = true diff --git a/mcp/src/http.ts b/mcp/src/http.ts index 4301e5b1..8675074d 100644 --- a/mcp/src/http.ts +++ b/mcp/src/http.ts @@ -4,7 +4,6 @@ import { createClientFactory, createUnscopedClient, parseConfig, - parseEnvConfig, type Env, } from "./config.js"; import { createServer } from "./server.js"; @@ -40,9 +39,33 @@ const MCP_PATHS = new Set(["/", "/mcp"]); type Session = { transport: WebStandardStreamableHTTPServerTransport; server: McpServer; + lastSeen: number; }; const sessions = new Map(); +const DEFAULT_SESSION_IDLE_MS = 30 * 60 * 1000; +const DEFAULT_SESSION_MAX = 128; + +function envInt(name: string, fallback: number): number { + const n = Number(process.env[name]); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +function dropSession(id: string): void { + const session = sessions.get(id); + if (!session) return; + sessions.delete(id); + void session.transport.close(); + void session.server.close(); +} + +function sweepSessions(): void { + const idleMs = envInt("MCP_SESSION_IDLE_MS", DEFAULT_SESSION_IDLE_MS); + const now = Date.now(); + for (const [id, session] of sessions) { + if (now - session.lastSeen > idleMs) dropSession(id); + } +} function envBindings(): Env { return { HONCHO_API_URL: process.env.HONCHO_API_URL }; @@ -80,25 +103,7 @@ function jsonResponse( } function configForRequest(request: Request) { - const auth = request.headers.get("Authorization")?.trim(); - if (auth) { - return parseConfig(request, envBindings()); - } - if (process.env.HONCHO_API_KEY?.trim()) { - const envConfig = parseEnvConfig({ - HONCHO_API_KEY: process.env.HONCHO_API_KEY, - HONCHO_API_URL: process.env.HONCHO_API_URL, - HONCHO_WORKSPACE_ID: process.env.HONCHO_WORKSPACE_ID, - }); - const headerWorkspace = - request.headers.get("X-Honcho-Workspace-ID")?.trim() || undefined; - return headerWorkspace - ? { ...envConfig, workspaceId: headerWorkspace } - : envConfig; - } - throw new Error( - "Missing Authorization header. Provide 'Authorization: Bearer '.", - ); + return parseConfig(request, envBindings()); } function unauthorized(request: Request, message: string): Response { @@ -113,10 +118,12 @@ function unauthorized(request: Request, message: string): Response { } async function handleMcp(request: Request): Promise { + sweepSessions(); const sessionId = request.headers.get("mcp-session-id"); if (sessionId) { const existing = sessions.get(sessionId); if (existing) { + existing.lastSeen = Date.now(); return withCors(await existing.transport.handleRequest(request)); } } @@ -178,10 +185,25 @@ async function handleMcp(request: Request): Promise { unscoped: createUnscopedClient(config), }); + const maxSessions = envInt("MCP_SESSION_MAX", DEFAULT_SESSION_MAX); + if (sessions.size >= maxSessions) { + return jsonResponse( + { + jsonrpc: "2.0", + error: { + code: -32000, + message: "Too many active sessions", + }, + id: null, + }, + 503, + ); + } + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), onsessioninitialized: (id) => { - sessions.set(id, { transport, server }); + sessions.set(id, { transport, server, lastSeen: Date.now() }); }, }); transport.onclose = () => {