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 <dragon@monstercode.com>
This commit is contained in:
Aakash Kattelu 2026-09-02 17:56:51 -04:00 committed by GitHub
parent b573a84806
commit 2ad56a4d71
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 517 additions and 14 deletions

View File

@ -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

7
mcp/.dockerignore Normal file
View File

@ -0,0 +1,7 @@
node_modules
.wrangler
.dev.vars
.env
.env.*
*.log
dist

19
mcp/Dockerfile Normal file
View File

@ -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"]

View File

@ -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 <key>"
```
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 <key>"

5
mcp/bunfig.toml Normal file
View File

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

View File

@ -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"
},

View File

@ -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,

56
mcp/src/http.test.ts Normal file
View File

@ -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<string, string>, 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);
});

281
mcp/src/http.ts Normal file
View File

@ -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<string, string | undefined>;
};
declare const Bun: {
serve(options: {
hostname: string;
port: number;
fetch(request: Request): Response | Promise<Response>;
}): { 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<string, string> = {
"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<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 };
}
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<string, string>,
): 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<Response> {
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<Response> {
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}`);
}

30
mcp/src/stdio.ts Normal file
View File

@ -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<string, string | undefined>;
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);
}

View File

@ -10,5 +10,5 @@
"types": ["@cloudflare/workers-types"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
"exclude": ["node_modules", "src/**/*.test.ts"]
}