Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions services/mcp-gateway/.dev.vars.example
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.
91 changes: 91 additions & 0 deletions services/mcp-gateway/AGENTS.md
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.
35 changes: 35 additions & 0 deletions services/mcp-gateway/README.md
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.
29 changes: 29 additions & 0 deletions services/mcp-gateway/package.json
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:"
}
}
12 changes: 12 additions & 0 deletions services/mcp-gateway/src/durable-objects/MCPGatewayInstance.do.ts
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);
}
19 changes: 19 additions & 0 deletions services/mcp-gateway/src/handlers/connect.handler.ts
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);
Copy link
Copy Markdown
Contributor

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 original params argument (which TypeScript accepts as UserConnectRouteParams but has not actually been validated at the call site — the caller passes c.req.param() which is Record<string, string>) is used implicitly by the stub response. This is currently harmless because the handler only calls notImplementedResponse, 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 in AGENTS.md.

Suggest:

const validatedParams = UserConnectRouteParamsSchema.parse(params);
// use validatedParams going forward

Same pattern applies to handleOrgConnect on line 17.

return notImplementedResponse(c);
}

export function handleOrgConnect(c: Context<MCPGatewayEnv>, params: OrgConnectRouteParams) {
OrgConnectRouteParamsSchema.parse(params);
return notImplementedResponse(c);
}
6 changes: 6 additions & 0 deletions services/mcp-gateway/src/handlers/health.handler.ts
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' });
}
29 changes: 29 additions & 0 deletions services/mcp-gateway/src/handlers/protected-resource.handler.ts
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);
Copy link
Copy Markdown
Contributor

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

Same issue as connect.handler.ts:12: UserConnectRouteParamsSchema.parse(params) validates but the returned object is ignored. Use the return value when real logic is added so Zod transforms/coercions are not silently bypassed. Same applies to OrgConnectRouteParamsSchema.parse(params) on line 27.

return notImplementedResponse(c);
}

export function handleOrgProtectedResourceMetadata(
c: Context<MCPGatewayEnv>,
params: OrgConnectRouteParams
) {
OrgConnectRouteParamsSchema.parse(params);
return notImplementedResponse(c);
}
6 changes: 6 additions & 0 deletions services/mcp-gateway/src/lib/responses.ts
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);
}
90 changes: 90 additions & 0 deletions services/mcp-gateway/src/mcp-gateway.worker.test.ts
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);
});
});
Loading
Loading