Skip to content
Merged
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
12 changes: 11 additions & 1 deletion docs/contributing/architecture/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,17 @@ routed from `packages/worker/src/index.ts`.

- Authorization endpoint: `/oauth/authorize`
- Token endpoint: `/oauth/token` (via provider)
- Client registration: `/oauth/register` (via provider)
- Client registration: `/oauth/register` (via provider), plus Client ID Metadata
Documents (`clientIdMetadataDocumentEnabled` in
`packages/worker/src/index.ts`): a client may present an HTTPS URL as its
`client_id` with no registration step. MCP `2026-07-28` deprecates RFC 7591
dynamic registration in favor of CIMD, so both stay enabled: clients that do
not use CIMD register via `/oauth/register`, and a failed CIMD metadata fetch
returns `invalid_client` (any DCR retry after that is the client's own
recovery, not a server-side fallback). CIMD metadata fetches rely on the
`global_fetch_strictly_public` compatibility flag in
`packages/worker/wrangler.jsonc` for SSRF safety; the provider only advertises
`client_id_metadata_document_supported` when both are set.
- Supported scopes: `profile`, `email`
- On `/oauth/authorize`, unauthenticated users can log in inline or via top-nav
auth links; those links preserve the full authorize URL in `redirectTo` so
Expand Down
4 changes: 4 additions & 0 deletions docs/contributing/architecture/data-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,10 @@ Bindings are configured per environment in `packages/worker/wrangler.jsonc`
[Usage metering](./usage-metering.md))
- `EMAIL_EVENTS` (Analytics Engine dataset, production/preview only; indexed by
stable user id and read only through role-gated platform aggregates)
- `MCP_PROTOCOL_EVENTS` (Analytics Engine dataset, production/preview only; one
point per authenticated `/mcp` request recording which protocol lane served it
— legacy sessionful vs stateless 2026-07-28 — for legacy-lane retirement; see
`packages/worker/src/mcp/protocol-metrics.ts`)

`packages/worker/wrangler.jsonc` also configures the `EMAIL` send binding,
dispatch queues, worker loaders (`LOADER` / `APP_LOADER`), the `AI` binding, and
Expand Down
11 changes: 10 additions & 1 deletion docs/contributing/architecture/request-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,16 @@ Requests are handled in this order:
the `/mcp` suffix path only):
- `/.well-known/oauth-protected-resource/mcp`
5. MCP endpoint:
- `/mcp` (requires OAuth bearer token)
- `/mcp` (requires OAuth bearer token). After authentication,
`packages/worker/src/mcp-auth.ts` routes by protocol era: 2025-era requests
go to the sessionful `MCP` Durable Object (`McpAgent`, MCP SDK v1), and
`2026-07-28` envelope requests are served statelessly per request by
`packages/worker/src/mcp/stateless-lane.ts` (MCP SDK v2, no Durable
Object). Both lanes share one tool registration; every authenticated
request records a lane data point to the `MCP_PROTOCOL_EVENTS` Analytics
Engine dataset so the legacy lane can be retired once its traffic stops
(see
[decision 0005](../decisions/0005-mcp-dual-lane-stateless-migration.md)).
6. Public `@username` ingress handled in `packages/worker/src/index.ts` before
the OAuth provider / app router (needs `ExecutionContext` for background
work):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# 0005: MCP dual-lane serving with metrics-driven legacy retirement

- **Status:** accepted
- **Date:** 2026-08-05

## Context

MCP protocol revision `2026-07-28` made the protocol stateless: the `initialize`
handshake and `Mcp-Session-Id` header are removed and every request carries its
own `_meta` envelope. The Cloudflare Agents SDK deprecated and feature-froze
`McpAgent`, which hosts kody's `/mcp` as a sessionful Durable Object on MCP SDK
v1. Nearly all installed MCP clients still speak 2025-era revisions, so dropping
the sessionful path outright would break real traffic, while staying on
`McpAgent` alone pins kody to a frozen stack.

## Decision

Serve `/mcp` as two lanes behind one route and one shared tool registration
(`packages/worker/src/mcp/register-tools.ts`): 2025-era requests keep the
`McpAgent` Durable Object lane unchanged, and `2026-07-28` envelope requests are
served by a per-request stateless SDK v2 server
(`packages/worker/src/mcp/stateless-lane.ts`). Routing uses the SDK's own
`isLegacyRequest` predicate, and every authenticated request records a lane data
point to the `MCP_PROTOCOL_EVENTS` Analytics Engine dataset — deliberately not
the primary D1 database (aggregate-only readout, off the hot path; consistent
with [0002](./0002-data-placement.md)). The legacy lane is removed when the
metrics show its traffic has gone (a sustained window of zero or negligible
legacy-lane requests from real clients), not on a calendar date.

## Consequences

Modern clients get stateless serving with no MCP session Durable Object on the
request path (the account write lease taken at the auth boundary is unchanged
and applies to both lanes — it is the deletion-safety guard every kody surface
takes, not MCP session state), while every existing client keeps byte-identical
behavior. Tool definitions cannot drift between lanes, but the two SDK
generations meet at a typed seam (`asMcpToolServer` in
`packages/worker/src/mcp/mcp-registration-agent.ts`) that a future SDK bump must
revisit. Retiring the legacy lane later also deletes the `mcp_agent_sessions`
registry, the `MCP_OBJECT` Durable Object, and the session purge path. Tasks
(the `io.modelcontextprotocol/tasks` extension) are deliberately not implemented
yet: the SDK v2 ships the vocabulary without a runtime, no major client supports
it, and execute's idempotency-key + `run_get` flow already covers the need;
revisit when a major host ships task support.
1 change: 1 addition & 0 deletions docs/contributing/decisions/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ behavior (see [documentation principles](../documentation.md)).
- [0002 — Data placement: D1, per-user Durable Objects, Analytics Engine](./0002-data-placement.md)
- [0003 — Repos are the base primitive; packages are an explicit extension](./0003-repos-as-base-primitive.md)
- [0004 — Status page as a separate worker with its own storage](./0004-status-page-separate-worker.md)
- [0005 — MCP dual-lane serving with metrics-driven legacy retirement](./0005-mcp-dual-lane-stateless-migration.md)
1 change: 1 addition & 0 deletions packages/worker/src/env-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ export const EnvSchema = object({
EMAIL_EVENTS: optionalAnalyticsEngineDatasetSchema,
USAGE_EVENTS: optionalAnalyticsEngineDatasetSchema,
FLAG_EXPOSURES: optionalAnalyticsEngineDatasetSchema,
MCP_PROTOCOL_EVENTS: optionalAnalyticsEngineDatasetSchema,
SENTRY_DSN: optionalUrlStringSchema,
SENTRY_ENVIRONMENT: optionalNonEmptyStringSchema,
SENTRY_TRACES_SAMPLE_RATE: optionalSentryTracesSampleRateSchema,
Expand Down
11 changes: 11 additions & 0 deletions packages/worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,17 @@ const oauthProvider = new OAuthProvider({
tokenEndpoint: oauthPaths.token,
clientRegistrationEndpoint: oauthPaths.register,
scopesSupported: oauthScopes,
// Client ID Metadata Documents (MCP 2025-11-25 SEP-991): clients may use
// an HTTPS URL as their client_id instead of registering via DCR. The
// 2026-07-28 revision deprecates RFC 7591 DCR in favor of CIMD, so both
// stay enabled: CIMD clients present their URL client_id with no
// registration step, and clients that do not use CIMD register via
// /oauth/register. A failed CIMD metadata fetch returns invalid_client;
// whether a client then registers via DCR is the client's own recovery.
// Requires the global_fetch_strictly_public compatibility flag (set in
// wrangler.jsonc) so metadata fetches are SSRF-safe; the provider only
// advertises CIMD support when both are on.
clientIdMetadataDocumentEnabled: true,
// Provider default onError logs every structured OAuth error via console.warn.
// Keep those responses on the wire without duplicating them into worker logs /
// test console guards; unexpected throws still reach our fetch catch + Sentry.
Expand Down
40 changes: 35 additions & 5 deletions packages/worker/src/mcp-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import {
} from './mcp/auth-audit.ts'
import { withAccountWriteLease } from '#worker/account/deletion-state.ts'
import { createMcpCallerContext, type McpServerProps } from './mcp/context.ts'
import {
classifyMcpProtocolRequest,
recordMcpProtocolEvent,
} from './mcp/protocol-metrics.ts'
import { handleStatelessMcpRequest } from './mcp/stateless-lane.ts'
import { oauthScopes } from './oauth-handlers.ts'

export const mcpResourcePath = '/mcp'
Expand Down Expand Up @@ -250,16 +255,41 @@ export async function handleMcpRequest({
})
context.props = props

// Lane classification: 2025-era ("legacy") requests keep the sessionful
// Durable Object McpAgent lane; 2026-07-28 envelope requests are served
// by the stateless SDK v2 lane. Every authenticated request records a
// lane data point so legacy-lane retirement is a metrics decision — see
// ./mcp/protocol-metrics.ts for the readout query.
const classification = await classifyMcpProtocolRequest(request)
recordMcpProtocolEvent(env, {
lane: classification.lane,
method: classification.method,
protocolVersion: classification.protocolVersion,
clientName: classification.clientName,
clientVersion: classification.clientVersion,
userId: mcpUser.userId,
})

return await withAccountWriteLease({
db: env.APP_DB,
stableUserId: mcpUser.userId,
holder: `mcp:${request.method} ${url.pathname}`,
env,
write: async () =>
await fetchMcp(
request,
env,
context as ExecutionContext<OAuthContextProps>,
),
classification.lane === 'legacy'
? await fetchMcp(
request,
env,
context as ExecutionContext<OAuthContextProps>,
)
: await handleStatelessMcpRequest({
request,
env,
ctx,
callerContext: props,
...(classification.parsedBody === undefined
? {}
: { parsedBody: classification.parsedBody }),
}),
Comment on lines 273 to +293

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Remove the account Durable Object from the modern request path.

handleStatelessMcpRequest runs inside withAccountWriteLease. The supplied lease implementation calls acquireDoAccountWriteLeaseAndWrite. Every modern request therefore depends on an account Durable Object. This defeats the stated stateless, no-Durable-Object lane design and adds a lease availability dependency to tools/list and other read-only requests.

  • packages/worker/src/mcp-auth.ts#L273-L293: Dispatch the modern branch outside withAccountWriteLease. Apply any required account-deletion protection at the specific mutating tool boundary.
  • docs/contributing/architecture/request-lifecycle.md#L54-L63: Keep the no-Durable-Object statement only after the modern branch bypasses the lease. Otherwise document the account Durable Object hop.
  • docs/contributing/decisions/0005-mcp-dual-lane-stateless-migration.md#L32-L34: Keep the no-Durable-Object consequence only after the modern branch bypasses the lease. Otherwise revise the decision record.
📍 Affects 3 files
  • packages/worker/src/mcp-auth.ts#L273-L293 (this comment)
  • docs/contributing/architecture/request-lifecycle.md#L54-L63
  • docs/contributing/decisions/0005-mcp-dual-lane-stateless-migration.md#L32-L34
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/mcp-auth.ts` around lines 273 - 293, The modern branch in
the MCP dispatch must bypass withAccountWriteLease and call
handleStatelessMcpRequest directly; retain the lease-wrapped path only for
legacy requests, and enforce account-deletion protection at the relevant
mutating tool boundary. In packages/worker/src/mcp-auth.ts:273-293, update the
dispatch accordingly. In
docs/contributing/architecture/request-lifecycle.md:54-63 and
docs/contributing/decisions/0005-mcp-dual-lane-stateless-migration.md:32-34,
retain the no-Durable-Object claims only if this bypass is implemented;
otherwise revise them to document the account Durable Object hop.

})
}
139 changes: 139 additions & 0 deletions packages/worker/src/mcp-auth.workers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,145 @@ test('mcp request enforces token audience and forwards caller props', async () =
)
}, 15_000)

test('mcp requests route by protocol era and record lane metrics', async () => {
const origin = 'https://example.com'
const validToken: TokenSummary = {
id: 'token',
grantId: 'grant',
userId: 'user',
createdAt: 0,
expiresAt: 999999,
audience: `${origin}${mcpResourcePath}`,
grant: {
clientId: 'client',
scope: oauthScopes,
props: { userId: 'user', email: 'user@example.com' },
},
}
const dataPoints: Array<AnalyticsEngineDataPoint> = []
const env = createEnv(
createHelpers({ unwrapToken: async () => validToken }),
{
MCP_PROTOCOL_EVENTS: {
writeDataPoint: (point: AnalyticsEngineDataPoint) => {
dataPoints.push(point)
},
} as AnalyticsEngineDataset,
},
{ emailVerifiedAt: new Date(0).toISOString() },
)
let legacyLaneCalls = 0
const fetchMcp = () => {
legacyLaneCalls += 1
return new Response('legacy-lane')
}

// 2025-era handshake stays on the sessionful Durable Object lane.
const legacyResponse = await handleMcpRequestAndDrain({
request: new Request(`${origin}${mcpResourcePath}`, {
method: 'POST',
headers: {
Authorization: 'Bearer token',
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'legacy-client', version: '1.0.0' },
},
}),
}),
env,
ctx: createContext(),
fetchMcp,
})
expect(await legacyResponse.text()).toBe('legacy-lane')
expect(legacyLaneCalls).toBe(1)
expect(dataPoints).toHaveLength(1)
expect(dataPoints[0]).toEqual({
indexes: ['legacy'],
blobs: [
'legacy',
'initialize',
'2025-06-18',
'legacy-client',
'1.0.0',
'user',
],
doubles: [1],
})

// 2026-07-28 envelope requests are served by the stateless lane and
// never reach the Durable Object; the advertised tools carry the shared
// definitions including output schemas and icons.
const modernResponse = await handleMcpRequestAndDrain({
request: new Request(`${origin}${mcpResourcePath}`, {
method: 'POST',
headers: {
Authorization: 'Bearer token',
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'MCP-Protocol-Version': '2026-07-28',
'Mcp-Method': 'tools/list',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 2,
method: 'tools/list',
params: {
_meta: {
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
'io.modelcontextprotocol/clientCapabilities': {},
'io.modelcontextprotocol/clientInfo': {
name: 'modern-client',
version: '2.0.0',
},
},
},
}),
}),
env,
ctx: createContext(),
fetchMcp,
})
expect(legacyLaneCalls).toBe(1)
expect(modernResponse.status).toBe(200)
const modernBody = (await modernResponse.json()) as {
result: {
resultType?: string
tools: Array<{
name: string
outputSchema?: Record<string, unknown>
icons?: Array<{ src: string }>
}>
}
}
const toolNames = modernBody.result.tools.map((tool) => tool.name).sort()
expect(toolNames).toEqual(['execute', 'search'])
for (const tool of modernBody.result.tools) {
expect(tool.outputSchema).toMatchObject({ type: 'object' })
expect(tool.icons?.[0]?.src).toBe(`${origin}/android-chrome-192x192.png`)
}
expect(dataPoints).toHaveLength(2)
expect(dataPoints[1]).toEqual({
indexes: ['modern'],
blobs: [
'modern',
'tools/list',
'2026-07-28',
'modern-client',
'2.0.0',
'user',
],
doubles: [1],
})
})

test('mcp request rejects unverified and unidentifiable accounts fail-closed', async () => {
const request = new Request(`https://example.com${mcpResourcePath}`, {
headers: { Authorization: 'Bearer token' },
Expand Down
31 changes: 30 additions & 1 deletion packages/worker/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { buildSentryOptions } from '../sentry-options.ts'
import { parseMcpCallerContext, type McpServerProps } from './context.ts'
import { buildMcpServerInstructions } from './server-instructions.ts'
import { registerTools } from './register-tools.ts'
import {
asMcpToolServer,
type McpRegistrationAgent,
} from './mcp-registration-agent.ts'
import { createKodyMcpServer } from './sentry-mcp-server.ts'
import { getMcpUserServerInstructions } from './user-server-instructions-repo.ts'
import { getCapabilityRegistryForContext } from './capabilities/registry.ts'
Expand Down Expand Up @@ -63,7 +67,32 @@ class MCPBase extends McpAgent<Env, State, Props> {
}),
jsonSchemaValidator: new CfWorkerJsonSchemaValidator(),
})
await registerTools(this)
await registerTools(this.getRegistrationAgent())
}
/**
* Registration surface shared with the stateless lane (see
* `asMcpToolServer` for the SDK v1/v2 seam). `state`/`setState` are
* forwarded live so tool runners keep their per-session behavior
* (search preamble dedup, raw-fetch host nudges) on this lane.
*/
getRegistrationAgent() {
const self = this
const agent: McpRegistrationAgent & {
state?: State
setState?: (state: State) => void
} = {
server: asMcpToolServer(this.server),
getEnv: () => self.getEnv(),
getCallerContext: () => self.getCallerContext(),
requireDomain: () => self.requireDomain(),
getLoopbackExports: () => self.getLoopbackExports(),
waitUntil: (promise) => self.waitUntil(promise),
get state() {
return self.state
},
setState: (state) => self.setState(state),
}
return agent
}
getCallerContext() {
return parseMcpCallerContext(this.props)
Expand Down
Loading
Loading