Repository navigation
Serve /mcp on dual protocol lanes: stateless 2026-07-28 + instrumented legacy, with CIMD and tool metadata #1237
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
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| # 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 Durable Object on the request path, | ||
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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' | ||
|
|
@@ -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
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. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift Remove the account Durable Object from the modern request path.
📍 Affects 3 files
🤖 Prompt for AI Agents |
||
| }) | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.