-
Notifications
You must be signed in to change notification settings - Fork 42
feat(mcp-gateway): scaffold runtime worker #3697
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| # MCP Gateway Worker local development values. | ||
| # | ||
| # This service is intentionally non-functional in PR1. It has no OAuth, | ||
| # provider, database, or proxy behavior yet. Wrangler reads non-secret vars | ||
| # from wrangler.jsonc; keep secrets out of this file until PR2 introduces | ||
| # the corresponding runtime behavior. | ||
| # | ||
| # Hyperdrive is configured in wrangler.jsonc. For local Postgres development, | ||
| # the binding uses the local connection string declared there. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| # MCP Gateway Conventions | ||
|
|
||
| ## Scope | ||
|
|
||
| `services/mcp-gateway` is the runtime plane for the Kilo MCP Gateway. The Next.js | ||
| app owns interactive OAuth, configuration CRUD, assignment management, provider | ||
| callbacks, gateway token issuance, and control-plane audit. This Worker owns scoped | ||
| runtime routing, protected-resource metadata, gateway-token verification, runtime | ||
| Postgres rechecks, upstream credential injection, streaming proxying, per-instance | ||
| refresh coordination, and runtime telemetry. | ||
|
|
||
| The Worker MUST NOT implement first-level OAuth authorization, token, registration, | ||
| provider callback, JWKS, user-info, config CRUD, assignment CRUD, or app management | ||
| routes in v1. | ||
|
|
||
| ## File naming | ||
|
|
||
| - Add a suffix matching the module type, for example `mcp-gateway.worker.ts`, | ||
| `MCPGatewayInstance.do.ts`, `connect.handler.ts`, `routes.schema.ts`, and | ||
| `instances.table.ts`. | ||
| - Modules that predominantly export a class should be named after that class. | ||
| - Keep pure helpers in `lib/` and keep route handlers in `handlers/`. | ||
|
|
||
| ## HTTP routes | ||
|
|
||
| - Define every exposed Hono route in `src/mcp-gateway.worker.ts` so the public | ||
| surface is visible in one file. | ||
| - Do not mount Hono sub-apps. | ||
| - Move route logic into `handlers/*.handler.ts` modules. | ||
| - Each handler takes the Hono context and a plain parsed params object. The route | ||
| declaration remains the source of truth for path-to-param shape. | ||
| - Runtime routes are scoped connect resources only: | ||
| - `/mcp-connect/user/{user_id}/{config_id}/{route_key}` | ||
| - `/mcp-connect/org/{org_id}/{config_id}/{route_key}` | ||
| - Protected-resource metadata is the only other public gateway surface owned by | ||
| this Worker. | ||
|
|
||
| ## IO boundaries | ||
|
|
||
| - Validate every IO boundary with Zod: MCP messages, route params, query params, | ||
| behavior-affecting headers, upstream responses, JSON parse results, SSE payloads, | ||
| subprocess output, and persisted session records. | ||
| - Raw parse and fetch helpers return `unknown`; callers parse with the relevant | ||
| Zod schema. | ||
| - Do not use `as` casts for IO shapes. Use schemas, `.passthrough()`, or explicit | ||
| catch-all schemas when the shape is intentionally broad. | ||
| - The gateway is stricter than Gastown at MCP protocol, header, query, upstream | ||
| response, and persisted-session boundaries. | ||
|
|
||
| ## Hyperdrive and Postgres | ||
|
|
||
| - Use `getWorkerDb(env.HYPERDRIVE.connectionString, { statement_timeout: ... })` | ||
| per request or per Durable Object use. | ||
| - Never cache pg pools, Drizzle clients, transaction objects, request-scoped state, | ||
| or other transport-owning SDK objects in module scope. | ||
| - Postgres remains the shared system of record for config, route, assignment, | ||
| identity, instance, and grant state. | ||
| - The Worker must re-check current Postgres state on every authenticated runtime | ||
| request before proxying, even when a Durable Object cache has older material. | ||
|
|
||
| ## Durable Objects | ||
|
|
||
| - `MCPGatewayInstance` is the per-instance runtime coordination atom. Its | ||
| deterministic key is `{owner_scope}:{owner_id}:{config_id}:{user_id}`. | ||
| - Do not introduce a global gateway Durable Object or a config-level DO that | ||
| serializes all users of a shared org config. | ||
| - Every DO module exports a `get{ClassName}Stub` helper, and callers use that | ||
| helper instead of accessing the namespace binding directly. | ||
| - Keep the DO class thin: RPC surface, alarms, and orchestration only. Move large | ||
| domain logic into plain-function submodules under a sibling directory when the | ||
| class grows beyond a few hundred lines. | ||
| - DO cache state is never authoritative for config, assignment, identity, route, | ||
| or grant eligibility. | ||
| - If DO SQLite is used, use tracked schema migrations from day one instead of ad | ||
| hoc `CREATE TABLE IF NOT EXISTS` drift. | ||
| - Use table interpolator objects and Zod row schemas for DO SQLite queries instead | ||
| of raw table or column strings and unsafe casts. | ||
|
|
||
| ## Security and streaming | ||
|
|
||
| - Route knowledge is not an authorization boundary. Every authenticated runtime | ||
| request must verify the exact scoped route, token audience, route key, config | ||
| status, identity, org membership, assignment, execution context, and instance | ||
| status. | ||
| - The client `Authorization` header is only for gateway authentication and must | ||
| never be forwarded upstream. | ||
| - Strip credential-like client headers before proxying, including `Authorization`, | ||
| `Proxy-Authorization`, `Cookie`, `X-API-Key`, `X-Auth-*`, and `X-Token-*`. | ||
| - Stream unknown request and response bodies. Do not buffer unbounded payloads. | ||
| - Do not log tokens, credentials, auth headers, cookies, webhook secrets, raw | ||
| provider payloads, or other secret material. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| # MCP Gateway | ||
|
|
||
| `services/mcp-gateway` is the Kilo MCP Gateway runtime Worker. PR1 intentionally | ||
| ships only the route skeleton for scoped MCP connect resources and protected-resource | ||
| metadata. The Worker is not attached to `mcp.kilo.ai` yet and does not implement OAuth, | ||
| provider discovery, database state, credential injection, or proxying. | ||
|
|
||
| ## Public surface in PR1 | ||
|
|
||
| - `GET /health` | ||
| - `GET|POST /mcp-connect/user/{user_id}/{config_id}/{route_key}` | ||
| - `GET|POST /mcp-connect/org/{org_id}/{config_id}/{route_key}` | ||
| - Optional descendant paths under each scoped connect route | ||
| - `GET /.well-known/oauth-protected-resource` | ||
| - `GET /.well-known/oauth-protected-resource/mcp-connect/user/{user_id}/{config_id}/{route_key}` | ||
| - `GET /.well-known/oauth-protected-resource/mcp-connect/org/{org_id}/{config_id}/{route_key}` | ||
|
|
||
| All runtime and protected-resource routes return `501 Not Implemented` in PR1. | ||
|
|
||
| ## Commands | ||
|
|
||
| ```bash | ||
| pnpm --filter cloudflare-mcp-gateway types | ||
| pnpm --filter cloudflare-mcp-gateway typecheck | ||
| pnpm --filter cloudflare-mcp-gateway test | ||
| pnpm --filter cloudflare-mcp-gateway lint | ||
| pnpm --filter cloudflare-mcp-gateway dev | ||
| ``` | ||
|
|
||
| ## Architecture | ||
|
|
||
| The Next.js app owns the interactive OAuth and control plane. This Worker owns the | ||
| runtime plane: protected-resource discovery, gateway-token verification, runtime | ||
| rechecks, upstream credential injection, streaming proxying, and per-instance refresh | ||
| coordination. The gateway architecture notes remain in the planning workspace until PR2. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| { | ||
| "name": "cloudflare-mcp-gateway", | ||
| "version": "1.0.0", | ||
| "type": "module", | ||
| "private": true, | ||
| "description": "Kilo MCP Gateway runtime worker", | ||
| "scripts": { | ||
| "dev": "wrangler dev --env dev --ip 0.0.0.0", | ||
| "start": "wrangler dev --env dev --ip 0.0.0.0", | ||
| "deploy": "wrangler deploy", | ||
| "types": "wrangler types --include-runtime=false", | ||
| "typecheck": "tsgo --noEmit", | ||
| "lint": "pnpm -w exec oxlint --config .oxlintrc.json services/mcp-gateway/src", | ||
| "test": "vitest run", | ||
| "test:watch": "vitest" | ||
| }, | ||
| "dependencies": { | ||
| "hono": "catalog:", | ||
| "zod": "catalog:" | ||
| }, | ||
| "devDependencies": { | ||
| "@cloudflare/workers-types": "catalog:", | ||
| "@types/node": "catalog:", | ||
| "@typescript/native-preview": "catalog:", | ||
| "typescript": "catalog:", | ||
| "vitest": "catalog:", | ||
| "wrangler": "catalog:" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import { DurableObject } from 'cloudflare:workers'; | ||
|
|
||
| export class MCPGatewayInstance extends DurableObject<Env> { | ||
| constructor(state: DurableObjectState, env: Env) { | ||
| super(state, env); | ||
| } | ||
| } | ||
|
|
||
| export function getMCPGatewayInstanceStub(env: Env, instanceKey: string) { | ||
| const id = env.MCP_GATEWAY_INSTANCE.idFromName(instanceKey); | ||
| return env.MCP_GATEWAY_INSTANCE.get(id); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import type { Context } from 'hono'; | ||
| import type { MCPGatewayEnv } from '../types'; | ||
| import { | ||
| OrgConnectRouteParamsSchema, | ||
| UserConnectRouteParamsSchema, | ||
| type OrgConnectRouteParams, | ||
| type UserConnectRouteParams, | ||
| } from '../schemas/routes.schema'; | ||
| import { notImplementedResponse } from '../lib/responses'; | ||
|
|
||
| export function handleUserConnect(c: Context<MCPGatewayEnv>, params: UserConnectRouteParams) { | ||
| UserConnectRouteParamsSchema.parse(params); | ||
| return notImplementedResponse(c); | ||
| } | ||
|
|
||
| export function handleOrgConnect(c: Context<MCPGatewayEnv>, params: OrgConnectRouteParams) { | ||
| OrgConnectRouteParamsSchema.parse(params); | ||
| return notImplementedResponse(c); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import type { Context } from 'hono'; | ||
| import type { MCPGatewayEnv } from '../types'; | ||
|
|
||
| export function handleHealth(c: Context<MCPGatewayEnv>) { | ||
| return c.json({ status: 'ok', service: 'mcp-gateway' }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import type { Context } from 'hono'; | ||
| import type { MCPGatewayEnv } from '../types'; | ||
| import { | ||
| OrgConnectRouteParamsSchema, | ||
| UserConnectRouteParamsSchema, | ||
| type OrgConnectRouteParams, | ||
| type UserConnectRouteParams, | ||
| } from '../schemas/routes.schema'; | ||
| import { notImplementedResponse } from '../lib/responses'; | ||
|
|
||
| export function handleProtectedResourceMetadata(c: Context<MCPGatewayEnv>) { | ||
| return notImplementedResponse(c); | ||
| } | ||
|
|
||
| export function handleUserProtectedResourceMetadata( | ||
| c: Context<MCPGatewayEnv>, | ||
| params: UserConnectRouteParams | ||
| ) { | ||
| UserConnectRouteParamsSchema.parse(params); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: Zod parse result is discarded — use the validated value Same issue as |
||
| return notImplementedResponse(c); | ||
| } | ||
|
|
||
| export function handleOrgProtectedResourceMetadata( | ||
| c: Context<MCPGatewayEnv>, | ||
| params: OrgConnectRouteParams | ||
| ) { | ||
| OrgConnectRouteParamsSchema.parse(params); | ||
| return notImplementedResponse(c); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import type { Context } from 'hono'; | ||
| import type { MCPGatewayEnv } from '../types'; | ||
|
|
||
| export function notImplementedResponse(c: Context<MCPGatewayEnv>) { | ||
| return c.json({ status: 'not_implemented' }, 501); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| import { describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| vi.mock('cloudflare:workers', () => ({ | ||
| DurableObject: class FakeDurableObject { | ||
| constructor(..._args: unknown[]) {} | ||
| }, | ||
| })); | ||
|
|
||
| import { app } from './mcp-gateway.worker'; | ||
|
|
||
| const userRoute = '/mcp-connect/user/user-123/config-123/route-123'; | ||
| const orgRoute = '/mcp-connect/org/org-123/config-123/route-123'; | ||
| const userMetadataRoute = `/.well-known/oauth-protected-resource${userRoute}`; | ||
| const orgMetadataRoute = `/.well-known/oauth-protected-resource${orgRoute}`; | ||
|
|
||
| async function request(path: string, method = 'GET') { | ||
| return app.request(`https://mcp.kilo.ai${path}`, { method }); | ||
| } | ||
|
|
||
| describe('MCP gateway route surface', () => { | ||
| it('returns health independently of runtime stubs', async () => { | ||
| const response = await request('/health'); | ||
|
|
||
| expect(response.status).toBe(200); | ||
| await expect(response.json()).resolves.toEqual({ status: 'ok', service: 'mcp-gateway' }); | ||
| }); | ||
|
|
||
| it('returns 501 for scoped runtime root routes', async () => { | ||
| const responses = await Promise.all([ | ||
| request(userRoute), | ||
| request(userRoute, 'POST'), | ||
| request(orgRoute), | ||
| request(orgRoute, 'POST'), | ||
| ]); | ||
|
|
||
| for (const response of responses) { | ||
| expect(response.status).toBe(501); | ||
| await expect(response.json()).resolves.toEqual({ status: 'not_implemented' }); | ||
| } | ||
| }); | ||
|
|
||
| it('returns 501 for scoped runtime descendant routes', async () => { | ||
| const responses = await Promise.all([ | ||
| request(`${userRoute}/tools/list`), | ||
| request(`${userRoute}/tools/list`, 'POST'), | ||
| request(`${orgRoute}/tools/list`), | ||
| request(`${orgRoute}/tools/list`, 'POST'), | ||
| ]); | ||
|
|
||
| for (const response of responses) { | ||
| expect(response.status).toBe(501); | ||
| } | ||
| }); | ||
|
|
||
| it('returns 501 for generic and scoped protected-resource metadata routes', async () => { | ||
| const responses = await Promise.all([ | ||
| request('/.well-known/oauth-protected-resource'), | ||
| request(userMetadataRoute), | ||
| request(orgMetadataRoute), | ||
| ]); | ||
|
|
||
| for (const response of responses) { | ||
| expect(response.status).toBe(501); | ||
| await expect(response.json()).resolves.toEqual({ status: 'not_implemented' }); | ||
| } | ||
| }); | ||
|
|
||
| it('does not expose app-owned OAuth or management routes', async () => { | ||
| const responses = await Promise.all([ | ||
| request('/oauth/authorize'), | ||
| request('/oauth/token', 'POST'), | ||
| request('/oauth/register', 'POST'), | ||
| request('/oauth/jwks.json'), | ||
| request('/oauth/userinfo'), | ||
| request('/oauth/mcp/callback'), | ||
| request('/api/mcp-gateway/available'), | ||
| request('/api/mcp-gateway/personal/configs'), | ||
| ]); | ||
|
|
||
| for (const response of responses) { | ||
| expect(response.status).toBe(404); | ||
| } | ||
| }); | ||
|
|
||
| it('does not expose legacy opaque connect routes', async () => { | ||
| const response = await request('/mcp-connect/opaque-connect-id'); | ||
|
|
||
| expect(response.status).toBe(404); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WARNING: Zod parse result is discarded — use the validated value
The result of
UserConnectRouteParamsSchema.parse(params)is thrown away and the originalparamsargument (which TypeScript accepts asUserConnectRouteParamsbut has not actually been validated at the call site — the caller passesc.req.param()which isRecord<string, string>) is used implicitly by the stub response. This is currently harmless because the handler only callsnotImplementedResponse, but as soon as real logic is added the handler will operate on the raw Hono path params rather than the Zod-validated and potentially-transformed object. It is also inconsistent with the IO-boundary convention documented inAGENTS.md.Suggest:
Same pattern applies to
handleOrgConnecton line 17.