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.
This commit is contained in:
Aakash Kattelu 2026-08-31 15:41:20 -04:00
parent 5c43b7e7f4
commit b1af9e410e
7 changed files with 342 additions and 13 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,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 <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.
`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 <key>"

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

View File

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

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

@ -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<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;
};
const sessions = new Map<string, Session>();
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) {
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>'.",
);
}
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> {
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<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}`);
}