From b1af9e410e99b35662763cdfefce6fd5084d3a7c Mon Sep 17 00:00:00 2001 From: Aakash Kattelu Date: Mon, 31 Aug 2026 15:41:20 -0400 Subject: [PATCH] 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. --- docker-compose.yml.example | 28 +++++ mcp/.dockerignore | 7 ++ mcp/Dockerfile | 19 +++ mcp/README.md | 56 +++++++-- mcp/package.json | 3 +- mcp/src/config.ts | 2 +- mcp/src/http.ts | 240 +++++++++++++++++++++++++++++++++++++ 7 files changed, 342 insertions(+), 13 deletions(-) create mode 100644 mcp/.dockerignore create mode 100644 mcp/Dockerfile create mode 100644 mcp/src/http.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 21f345db..59fe1e1c 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,7 +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 run stdio) + 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 @@ -65,25 +66,56 @@ 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). 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. + +`HOST` defaults to `0.0.0.0`, `PORT` to `3000`. `GET /health` is unauthenticated. +MCP is served at `/` and `/mcp`. + +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 @@ -126,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/package.json b/mcp/package.json index 7f24e94e..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": { @@ -12,6 +12,7 @@ "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 ea15c135..ee8067e3 100644 --- a/mcp/src/config.ts +++ b/mcp/src/config.ts @@ -54,7 +54,7 @@ export function parseConfig(request: Request, env: Env = {}): HonchoConfig { }; } -/** Parse configuration from process env for the stdio host. */ +/** Parse configuration from process env. */ export function parseEnvConfig(env: EnvConfig): HonchoConfig { const apiKey = env.HONCHO_API_KEY?.trim(); if (!apiKey) { diff --git a/mcp/src/http.ts b/mcp/src/http.ts new file mode 100644 index 00000000..4301e5b1 --- /dev/null +++ b/mcp/src/http.ts @@ -0,0 +1,240 @@ +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; +import { + createClientFactory, + createUnscopedClient, + parseConfig, + parseEnvConfig, + type Env, +} 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; +}; + +const sessions = new Map(); + +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) { + 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 '.", + ); +} + +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 { + const sessionId = request.headers.get("mcp-session-id"); + if (sessionId) { + const existing = sessions.get(sessionId); + if (existing) { + 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, + ); + } + + let config; + try { + config = configForRequest(request); + } catch (e) { + const message = e instanceof Error ? e.message : "Invalid request"; + return unauthorized(request, message); + } + + const server = createServer({ + config, + clientFor: createClientFactory(config), + unscoped: createUnscopedClient(config), + }); + + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + onsessioninitialized: (id) => { + sessions.set(id, { transport, server }); + }, + }); + 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}`); +}