From 2ad56a4d715b015d744ea86f05a287069d264898 Mon Sep 17 00:00:00 2001 From: Aakash Kattelu Date: Wed, 2 Sep 2026 17:56:51 -0400 Subject: [PATCH] feat(mcp): add stdio host for local clients (#1102) * feat(mcp): add stdio host for local clients * feat(mcp): add Streamable HTTP host and image Long-lived HTTP entry for Docker and other process hosts, reusing createServer(). Dedicated mcp/Dockerfile; compose service beside api. * 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. * fix(mcp): re-check bearer on established HTTP sessions Session lookup returned early without Authorization, so a missing or wrong token still 200'd after initialize. Bind each session to the init key and 401 on mismatch. * fix: nit cleaning claude command --------- Co-authored-by: ajspig --- docker-compose.yml.example | 28 ++++ mcp/.dockerignore | 7 + mcp/Dockerfile | 19 +++ mcp/README.md | 74 ++++++++-- mcp/bunfig.toml | 5 + mcp/package.json | 4 +- mcp/src/config.ts | 25 +++- mcp/src/http.test.ts | 56 ++++++++ mcp/src/http.ts | 281 +++++++++++++++++++++++++++++++++++++ mcp/src/stdio.ts | 30 ++++ mcp/tsconfig.json | 2 +- 11 files changed, 517 insertions(+), 14 deletions(-) create mode 100644 mcp/.dockerignore create mode 100644 mcp/Dockerfile create mode 100644 mcp/bunfig.toml create mode 100644 mcp/src/http.test.ts create mode 100644 mcp/src/http.ts create mode 100644 mcp/src/stdio.ts diff --git a/docker-compose.yml.example b/docker-compose.yml.example index ee201e6f..4bfae9f7 100644 --- a/docker-compose.yml.example +++ b/docker-compose.yml.example @@ -80,6 +80,34 @@ services: required: false restart: unless-stopped + mcp: + build: + context: ./mcp + dockerfile: Dockerfile + depends_on: + api: + condition: service_healthy + ports: + - "127.0.0.1:3000:3000" + environment: + - HONCHO_API_URL=http://api:8000 + env_file: + - path: .env + required: false + healthcheck: + test: + [ + "CMD", + "bun", + "-e", + "fetch('http://127.0.0.1:3000/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 5s + timeout: 5s + retries: 5 + start_period: 10s + restart: unless-stopped + database: image: pgvector/pgvector:pg15 restart: unless-stopped diff --git a/mcp/.dockerignore b/mcp/.dockerignore new file mode 100644 index 00000000..62b547a4 --- /dev/null +++ b/mcp/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.wrangler +.dev.vars +.env +.env.* +*.log +dist diff --git a/mcp/Dockerfile b/mcp/Dockerfile new file mode 100644 index 00000000..60e0a0d1 --- /dev/null +++ b/mcp/Dockerfile @@ -0,0 +1,19 @@ +FROM oven/bun:1.2 + +WORKDIR /app + +RUN chown bun:bun /app +USER bun + +COPY --chown=bun:bun package.json bun.lock bunfig.toml tsconfig.json ./ +COPY --chown=bun:bun instructions.md ./ +COPY --chown=bun:bun src ./src + +RUN bun install --frozen-lockfile --production + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOST=0.0.0.0 + +CMD ["bun", "src/http.ts"] diff --git a/mcp/README.md b/mcp/README.md index 3bd24217..94abd88a 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -1,6 +1,6 @@ # Honcho MCP Server -A Cloudflare Worker that implements the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) for [Honcho](https://honcho.dev), providing AI memory and personalization tools to LLM clients like Claude Desktop. +A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server for [Honcho](https://honcho.dev). The hosted path is a Cloudflare Worker; the same tools also run over stdio and over Streamable HTTP (`bun src/http.ts`) for Docker and other long-lived process hosts. ## Quickstart: Use the Hosted Server @@ -45,6 +45,8 @@ Every workspace-scoped tool takes a `workspace_id` argument. If you set `X-Honch ``` src/ index.ts # Worker entry point — parse config, delegate to MCP handler + stdio.ts # Local stdio host (bun src/stdio.ts) + http.ts # Streamable HTTP host (bun src/http.ts / Docker) server.ts # createServer() — registers all tools on an McpServer config.ts # HonchoConfig, parseConfig(), createClientFactory() types.ts # ToolContext, result helpers @@ -64,25 +66,75 @@ Built on: ## Self-Hosted Honcho -If you run Honcho yourself (for privacy, latency, or offline use), deploy the -MCP Worker alongside your instance and set `HONCHO_API_URL` in its -environment. +If you run Honcho yourself, point this server at it with `HONCHO_API_URL`. +When unset, requests go to `https://api.honcho.dev`. -**Local dev (`bun run dev`):** create `mcp/.dev.vars`: +**Cloudflare Worker (`bun run dev` / `bun run deploy`):** create `mcp/.dev.vars`: ``` HONCHO_API_URL=http://127.0.0.1:28000 ``` -**Deployed Worker:** +For a deployed Worker: `wrangler secret put HONCHO_API_URL`. + +## HTTP host + +For Docker or any platform that runs a long-lived process, use the Streamable +HTTP entry instead of the Worker. Clients keep the same `mcp-remote` shape as +`https://mcp.honcho.dev`. Sessions live in process memory — run one instance. ```bash -wrangler secret put HONCHO_API_URL -# paste your URL when prompted +cd mcp && bun install +HONCHO_API_URL=http://127.0.0.1:8000 bun run http ``` -When `HONCHO_API_URL` is unset the Worker routes to `https://api.honcho.dev`, -so this change is backward-compatible. +```bash +bunx mcp-remote http://127.0.0.1:3000 \ + --header "Authorization:Bearer " +``` + +Auth is the `Authorization: Bearer` header (same as the Worker). Established +sessions still require that same bearer. 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`. 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 +in-memory session map. + +### Docker + +```bash +docker build -f mcp/Dockerfile -t honcho-mcp mcp +docker run --rm -p 3000:3000 \ + -e HONCHO_API_URL=http://host.docker.internal:8000 \ + honcho-mcp +``` + +`docker-compose.yml.example` includes an `mcp` service beside `api` and +`deriver` (`HONCHO_API_URL=http://api:8000`, port `127.0.0.1:3000`). + +## Local stdio + +For a local Honcho instance, or any MCP client that spawns a process, run the +stdio host. `--cwd` loads `mcp/bunfig.toml` (Markdown loader) from this package. + +```bash +cd mcp && bun install + +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 --cwd "$(pwd)" src/stdio.ts +``` + +`HONCHO_API_URL` defaults to `https://api.honcho.dev`. `HONCHO_WORKSPACE_ID` is +optional; without it, pass `workspace_id` on each tool call. ## Development @@ -106,6 +158,8 @@ bun run tsc --noEmit ### Test locally +Worker (`bun dev`, port 8787) or HTTP host (`bun run http`, port 3000): + ```bash bunx mcp-remote http://localhost:8787 \ --header "Authorization:Bearer " diff --git a/mcp/bunfig.toml b/mcp/bunfig.toml new file mode 100644 index 00000000..9d1af97a --- /dev/null +++ b/mcp/bunfig.toml @@ -0,0 +1,5 @@ +[loader] +".md" = "text" + +[run] +silent = true diff --git a/mcp/package.json b/mcp/package.json index 900df5cf..231ec495 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,7 +1,7 @@ { "name": "honcho-mcp", "version": "3.0.0", - "description": "Honcho MCP Server — Cloudflare Worker", + "description": "Honcho MCP Server", "main": "src/index.ts", "packageManager": "bun@1.2.0", "engines": { @@ -11,6 +11,8 @@ "scripts": { "preinstall": "node -e \"const ua=process.env.npm_config_user_agent||'';if(ua.includes('npm')&&!ua.includes('bun')){console.error('❌ Please use bun instead of npm!\\n📦 Run: bun install\\n🌐 Install bun: https://bun.sh/');process.exit(1)}\"", "dev": "wrangler dev", + "stdio": "bun src/stdio.ts", + "http": "bun src/http.ts", "deploy": "wrangler deploy", "deploy:staging": "wrangler deploy --env staging" }, diff --git a/mcp/src/config.ts b/mcp/src/config.ts index ed70bbc1..ee8067e3 100644 --- a/mcp/src/config.ts +++ b/mcp/src/config.ts @@ -3,7 +3,7 @@ import { Honcho } from "@honcho-ai/sdk"; export interface HonchoConfig { apiKey: string; baseUrl: string; - /** From X-Honcho-Workspace-ID when set. */ + /** From X-Honcho-Workspace-ID (HTTP) or HONCHO_WORKSPACE_ID (stdio). */ workspaceId?: string; } @@ -12,6 +12,12 @@ export interface Env { ALERT_WEBHOOK_URL?: string; } +export interface EnvConfig { + HONCHO_API_KEY?: string; + HONCHO_API_URL?: string; + HONCHO_WORKSPACE_ID?: string; +} + /** * Parse configuration from request headers and Worker env bindings. * Throws only when the Authorization bearer token is missing/empty. @@ -48,8 +54,23 @@ export function parseConfig(request: Request, env: Env = {}): HonchoConfig { }; } +/** Parse configuration from process env. */ +export function parseEnvConfig(env: EnvConfig): HonchoConfig { + const apiKey = env.HONCHO_API_KEY?.trim(); + if (!apiKey) { + throw new Error( + "Missing HONCHO_API_KEY. Set HONCHO_API_KEY to your Honcho API key.", + ); + } + return { + apiKey, + baseUrl: env.HONCHO_API_URL?.trim() || "https://api.honcho.dev", + workspaceId: env.HONCHO_WORKSPACE_ID?.trim() || undefined, + }; +} + export const MISSING_WORKSPACE_ID_MESSAGE = - "Missing workspace_id. Pass workspace_id on the next tool call, or set the X-Honcho-Workspace-ID header on the connection so it is used automatically."; + "Missing workspace_id. Pass workspace_id on the next tool call, or set X-Honcho-Workspace-ID (HTTP) / HONCHO_WORKSPACE_ID (stdio)."; export function resolveWorkspaceId( config: HonchoConfig, diff --git a/mcp/src/http.test.ts b/mcp/src/http.test.ts new file mode 100644 index 00000000..385f84eb --- /dev/null +++ b/mcp/src/http.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from "bun:test"; +import { fetch } from "./http.ts"; + +const origin = "http://127.0.0.1:3000"; + +const initializeBody = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "test", version: "0.0.0" }, + }, +}; + +const pingBody = { jsonrpc: "2.0", id: 2, method: "ping" }; + +function mcpPost(headers: Record, body: unknown) { + return fetch( + new Request(`${origin}/mcp`, { + method: "POST", + headers: { + Accept: "application/json, text/event-stream", + "Content-Type": "application/json", + ...headers, + }, + body: JSON.stringify(body), + }), + ); +} + +test("established sessions require the initialize bearer", async () => { + const init = await mcpPost( + { Authorization: "Bearer key-a" }, + initializeBody, + ); + expect(init.status).toBe(200); + const sessionId = init.headers.get("mcp-session-id"); + expect(sessionId).toBeTruthy(); + + const missing = await mcpPost({ "mcp-session-id": sessionId! }, pingBody); + expect(missing.status).toBe(401); + + const wrong = await mcpPost( + { Authorization: "Bearer key-b", "mcp-session-id": sessionId! }, + pingBody, + ); + expect(wrong.status).toBe(401); + + const ok = await mcpPost( + { Authorization: "Bearer key-a", "mcp-session-id": sessionId! }, + pingBody, + ); + expect(ok.status).toBe(200); +}); diff --git a/mcp/src/http.ts b/mcp/src/http.ts new file mode 100644 index 00000000..a33bd4a8 --- /dev/null +++ b/mcp/src/http.ts @@ -0,0 +1,281 @@ +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; +import { + createClientFactory, + createUnscopedClient, + parseConfig, + type Env, + type HonchoConfig, +} from "./config.js"; +import { createServer } from "./server.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +declare const process: { + env: Record; +}; + +declare const Bun: { + serve(options: { + hostname: string; + port: number; + fetch(request: Request): Response | Promise; + }): { hostname: string; port: number }; +}; + +const CORS_ORIGIN = "*"; +const CORS_METHODS = "GET, POST, DELETE, OPTIONS"; +const CORS_ALLOWED_HEADERS = + "Content-Type, Authorization, X-Honcho-Workspace-ID, mcp-session-id, mcp-protocol-version, last-event-id"; + +const CORS_HEADERS: Record = { + "Access-Control-Allow-Origin": CORS_ORIGIN, + "Access-Control-Allow-Methods": CORS_METHODS, + "Access-Control-Allow-Headers": CORS_ALLOWED_HEADERS, + "Access-Control-Expose-Headers": "WWW-Authenticate, mcp-session-id", +}; + +const PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource"; +const MCP_PATHS = new Set(["/", "/mcp"]); + +type Session = { + transport: WebStandardStreamableHTTPServerTransport; + server: McpServer; + lastSeen: number; + apiKey: string; +}; + +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 }; +} + +function authorizationServer(): string { + return process.env.HONCHO_API_URL?.trim() || "https://api.honcho.dev"; +} + +function withCors(response: Response): Response { + const headers = new Headers(response.headers); + for (const [key, value] of Object.entries(CORS_HEADERS)) { + headers.set(key, value); + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +function jsonResponse( + body: unknown, + status: number, + extraHeaders?: Record, +): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + "Content-Type": "application/json", + ...CORS_HEADERS, + ...extraHeaders, + }, + }); +} + +function configForRequest(request: Request) { + return parseConfig(request, envBindings()); +} + +function configOrUnauthorized(request: Request): HonchoConfig | Response { + try { + return configForRequest(request); + } catch (e) { + const message = e instanceof Error ? e.message : "Invalid request"; + return unauthorized(request, message); + } +} + +function unauthorized(request: Request, message: string): Response { + const resourceMetadata = `${new URL(request.url).origin}${PROTECTED_RESOURCE_PATH}`; + return jsonResponse( + { error: message }, + 401, + { + "WWW-Authenticate": `Bearer resource_metadata="${resourceMetadata}"`, + }, + ); +} + +async function handleMcp(request: Request): Promise { + sweepSessions(); + const sessionId = request.headers.get("mcp-session-id"); + if (sessionId) { + const existing = sessions.get(sessionId); + if (existing) { + const config = configOrUnauthorized(request); + if (config instanceof Response) return config; + if (config.apiKey !== existing.apiKey) { + return unauthorized( + request, + "Authorization does not match this session.", + ); + } + existing.lastSeen = Date.now(); + return withCors(await existing.transport.handleRequest(request)); + } + } + + if (request.method !== "POST") { + return jsonResponse( + { + jsonrpc: "2.0", + error: { + code: -32000, + message: "Bad Request: No valid session ID provided", + }, + id: null, + }, + 400, + ); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return jsonResponse( + { + jsonrpc: "2.0", + error: { code: -32700, message: "Parse error: Invalid JSON" }, + id: null, + }, + 400, + ); + } + + const messages = Array.isArray(body) ? body : [body]; + if (!messages.some((message) => isInitializeRequest(message))) { + return jsonResponse( + { + jsonrpc: "2.0", + error: { + code: -32000, + message: "Bad Request: No valid session ID provided", + }, + id: null, + }, + 400, + ); + } + + const config = configOrUnauthorized(request); + if (config instanceof Response) return config; + + const server = createServer({ + config, + clientFor: createClientFactory(config), + 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, + lastSeen: Date.now(), + apiKey: config.apiKey, + }); + }, + }); + transport.onclose = () => { + const id = transport.sessionId; + if (id) sessions.delete(id); + }; + + await server.connect(transport); + return withCors( + await transport.handleRequest(request, { parsedBody: body }), + ); +} + +export async function fetch(request: Request): Promise { + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + + const pathname = new URL(request.url).pathname; + + if (pathname === "/health") { + return jsonResponse({ status: "ok" }, 200); + } + + if (pathname === PROTECTED_RESOURCE_PATH) { + return jsonResponse( + { + resource: new URL(request.url).origin, + authorization_servers: [authorizationServer()], + bearer_methods_supported: ["header"], + scopes_supported: ["read", "write"], + }, + 200, + ); + } + + if (!MCP_PATHS.has(pathname)) { + return jsonResponse({ error: "Not Found" }, 404); + } + + try { + return await handleMcp(request); + } catch (e) { + const message = + e instanceof Error ? e.message : "Internal server error"; + return jsonResponse({ error: message }, 500); + } +} + +const isMain = Boolean((import.meta as { main?: boolean }).main); +if (isMain) { + const hostname = process.env.HOST?.trim() || "0.0.0.0"; + const port = Number(process.env.PORT) || 3000; + Bun.serve({ hostname, port, fetch }); + console.error(`honcho-mcp listening on http://${hostname}:${port}`); +} diff --git a/mcp/src/stdio.ts b/mcp/src/stdio.ts new file mode 100644 index 00000000..3a351651 --- /dev/null +++ b/mcp/src/stdio.ts @@ -0,0 +1,30 @@ +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + createClientFactory, + createUnscopedClient, + parseEnvConfig, +} from "./config.js"; +import { createServer } from "./server.js"; + +declare const process: { + env: Record; + exit(code?: number): never; +}; + +try { + const config = 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 server = createServer({ + config, + clientFor: createClientFactory(config), + unscoped: createUnscopedClient(config), + }); + await server.connect(new StdioServerTransport()); +} catch (e) { + const message = e instanceof Error ? e.message : String(e); + console.error(message); + process.exit(1); +} diff --git a/mcp/tsconfig.json b/mcp/tsconfig.json index 8ca7bfae..8bebbfa9 100644 --- a/mcp/tsconfig.json +++ b/mcp/tsconfig.json @@ -10,5 +10,5 @@ "types": ["@cloudflare/workers-types"] }, "include": ["src/**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "src/**/*.test.ts"] }