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:
parent
5c43b7e7f4
commit
b1af9e410e
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
node_modules
|
||||
.wrangler
|
||||
.dev.vars
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
dist
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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>"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
Loading…
Reference in New Issue