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.
This commit is contained in:
Aakash Kattelu 2026-08-31 15:50:45 -04:00
parent b1af9e410e
commit c154f421db
3 changed files with 53 additions and 29 deletions

View File

@ -93,13 +93,13 @@ bunx mcp-remote http://127.0.0.1:3000 \
--header "Authorization:Bearer <key>"
```
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

View File

@ -1,2 +1,5 @@
[loader]
".md" = "text"
[run]
silent = true

View File

@ -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<string, Session>();
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 <your-honcho-key>'.",
);
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<Response> {
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<Response> {
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 = () => {