Your memory of this stack comes from the McpAgent + SSE + @modelcontextprotocol/sdk v1 era.
Cloudflare deprecated that path in agents 0.20.0 (McpAgent is feature-frozen).
The MCP 2026-07-28 revision removed the initialize handshake and protocol sessions.
As of 2026-09-28, tutorials, older skills and Cloudflare's own OAuth template show the old shapes.
Anchor every decision on one question:
Which protocol era does each client speak, and which handler serves it?
- Legacy era:
2025-11-25and earlier, starts withinitialize. - Modern era:
2026-07-28, no handshake, per-request_meta. - The stateless
createMcpHandler(factory)serves both on one route by default (verified live; matrix inreferences/spec.mdsection 4). - An SDK v1 server serves only the legacy era. A modern client gets
400 Unsupported protocol version: 2026-07-28. - Only reach for a sessionful legacy handler when a 2025-era client needs server-to-client requests (push elicitation, sampling, roots).
1. Check versions first
agents exact-pins its MCP SDK peers, and npm latest of the SDK runs ahead of the pin.
Read the pins before installing anything:
pnpm view agents@latest version
pnpm view agents@latest peerDependencies --json
Verified 2026-09-28: agents 0.24.0 pins @modelcontextprotocol/server 2.0.0, @modelcontextprotocol/client 2.0.0, @modelcontextprotocol/sdk 1.30.0, zod ^4.0.0.
npm latest of @modelcontextprotocol/server was 2.1.0 on that date.
If agents@latest is inside the install quarantine, use the newest agents outside it and that version's peers. Do not bypass the quarantine.
When scaffolding from assets/stateless-server/, only confirm its package.json pins match the live peer pins. Edit them only if they differ.
Otherwise install exactly the pinned versions:
- Write the exact pin (no
^) foragentsand each@modelcontextprotocol/*package intopackage.json, for example"@modelcontextprotocol/server": "2.0.0". - Write
zodas a range:"zod": "^4.2.0". It satisfies the agents peer, and 4.0-4.1 drop.describe()text from tool schemas (only a one-time[mcp-sdk]console warning shows it). Zod 3 is unsupported. - Run
pnpm install. - Run
pnpm peers check(pnpm 11+). It must report no unmet@modelcontextprotocol/*peer.
pnpm add resolves the SDK past the pin, and --save-exact does not rewrite an existing ^ range. Edit package.json.
2. The default: stateless handler
// src/index.ts
import { McpServer } from "@modelcontextprotocol/server";
import { createMcpHandler } from "agents/mcp/server";
import { z } from "zod";
function createServer() {
const server = new McpServer({ name: "my-mcp", version: "1.0.0" });
server.registerTool(
"hello",
{
description: "Returns a greeting",
inputSchema: z.object({ name: z.string().optional().describe("Who to greet") }),
},
async ({ name }) => ({ content: [{ type: "text", text: `Hello, ${name ?? "World"}!` }] }),
);
return server;
}
const handler = createMcpHandler(createServer);
export default {
fetch: (request, env, ctx) => handler(request, env, ctx),
} satisfies ExportedHandler<Env>;
// wrangler.jsonc
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "my-mcp",
"main": "src/index.ts",
"compatibility_date": "YYYY-MM-DD", // the day you create the project, never a template's date
"compatibility_flags": ["nodejs_compat"],
"observability": { "enabled": true }
}
nodejs_compatis required:agents/mcp/serverimportsnode:async_hooks.- No Durable Object binding and no
migrationsblock. The stateless handler needs neither. - Generate
Envwithpnpm exec wrangler types. Do not hand-writeenv.d.tsor add@cloudflare/workers-types4. - Canonical upstream: examples/mcp-worker and the handler API.
As of 2026-09-28 both pass a raw shape to
inputSchema. The SDK v2 d.ts marks that overload@deprecated Wrap with z.object({...}).
3. Standing rules
- Import
createMcpHandlerfromagents/mcp/server, notagents/mcp.agents/mcpis the legacy barrel with an overloadedcreateMcpHandler, and it pullsMcpAgentand SDK v1 into the bundle (per the 0.20.0 changelog). - Import
McpServerfrom@modelcontextprotocol/server.@modelcontextprotocol/sdk/server/mcp.jsis SDK v1, which serves only the legacy era. - Pass the factory, never a server instance.
createMcpHandler(createServer)builds a fresh server per request. Fromagents/mcp, a v1 instance logs a deprecation warning and returns 400 to modern clients. Fromagents/mcp/server, any instance fails typecheck and every request returns 500 (observed). - Export an object with
fetch, never the handler itself. The agents handler is a callable function, and Wrangler treats a function default export as aWorkerEntrypointclass. Use the agentscreateMcpHandler, not the one from@modelcontextprotocol/server. The SDK one does no Origin/Host validation. - Create the handler once at module scope.
handler.notify.*reachessubscriptions/listenstreams only from the module-scope handler. Notifications are isolate-local, and the agents wrapper throwsTypeErroron a custombus(observed). - Read bindings with
import { env } from "cloudflare:workers". The factory gets noenv, and a handler built insidefetchto capture it breaks notifications (rule above). - Use
registerTool(name, { description, inputSchema: z.object({...}) }, cb). SDK v2 has noserver.tool(),server.resource()orserver.prompt(). - Read the SDK v2 callback
context, not v1extra.extra.authInfoandextra.requestIddo not exist in v2. - Keep the default
legacy: "stateless"on a public server.legacy: "reject"locks out every 2025-era client. Which era Claude and ChatGPT speak is unverified. - Set
allowedHostnames: ["mcp.example.com"]on a custom domain. Host checks run by default only on localhost and*.workers.dev. - Elicit with
inputRequired(...), notelicitInput.context.mcpReq.elicitInputthrows on a modern request.inputRequiredreaches only modern clients. A 2025-era client gets anisErrorresult, so give the tool a non-elicitation fallback or serve a sessionful legacy lane (references/handler.mdsection 6). - Scaffold from
assets/stateless-server/, not a template.cloudflare/aidemos/remote-mcp-github-oauthisMcpAgent+ provider 0.x (as of 2026-09-28). There is noagentsCLI (gone since 0.22.0), so do not runnpm createoragents create. - Never suppress a type error with
@ts-expect-errorto make a handler fit. Wrap it in{ fetch: (r, e, c) => app.fetch(r, e, c) }instead. Hono'sc.executionCtxneeds anas ExecutionContextcast (references/handler.mdsection 9).
4. Stale patterns
Distrust McpAgent, serveSSE, server.tool( and developers.cloudflare.com/agents/mcp/ URLs (that path returns 404) in older skills, blog posts and your own training data.
| Stale | Current |
|---|---|
class MyMCP extends McpAgent + DO binding + migrations |
createMcpHandler(createServer) from agents/mcp/server, no DO. When migrating, keep applied migrations entries and append deleted_classes (references/migrate.md) |
MyMCP.serveSSE("/sse") |
one /mcp route serving both eras, no /sse route (HTTP+SSE is deprecated in 2026-07-28) |
@modelcontextprotocol/sdk + server.tool(name, desc, shape, cb) |
@modelcontextprotocol/server + registerTool(name, { inputSchema: z.object(...) }, cb) |
OAuthProvider({ apiHandlers: { "/mcp": MyMCP.serve("/mcp") } }), no resourceMetadata |
provider 1.x with resourceMetadata: { resource }, apiRoute: "/mcp" and apiHandler: { fetch: (r, e, c) => mcp(r, e, c) } (1.1.0 throws without apiRoute) |
Full table and step-by-step migrations: references/migrate.md.
5. Auth in brief
- Use
@cloudflare/workers-oauth-provider1.x. Its constructor throws unlessresourceMetadata.resourceis an absolute https URL (httponly on loopback) that coversapiRoute. It cannot tell whether the value is your real public URL, so set it to the exact public MCP URL yourself. Claude requires PRMresourceto match the URL the user enters. - Read identity in tools from
getMcpAuthContext()?.props(fromagents/mcp/server).context.http?.authInfoisundefinedbehind every released provider, whatever Cloudflare's docs andmcp-worker-authenticatedshow (runtime probe with a simulated providerctx). - Enforce scopes in an
apiHandlerwrapper that returnsinsufficientScope(ctx.auth, [...])before calling the MCP handler. - Prefer CIMD (
clientIdMetadataDocumentEnabled: true+ compat flagglobal_fetch_strictly_public) over DCR. Use the provider's consent and upstream helpers, not hand-rolledstateor cookies.
Everything else, including the security checklist and Claude/ChatGPT connector requirements: references/auth.md.
6. Verify
- Run
pnpm test. - Before calling any server done, run the both-eras client probe against
wrangler devand again against the deployed URL. In a template project runpnpm probe <url-ending-in-/mcp>. Elsewhere, copyassets/stateless-server/scripts/probe.mjsand add@modelcontextprotocol/clientat the exactagentspeer pin. It must call a tool successfully in legacy mode,automode and pinned2026-07-28mode. Adapt its tool call to your server; details inreferences/testing-deploy.md. - Report which of those checks ran and which were skipped.
A curl of tools/list proves only the legacy lane.
Inspector: see references/testing-deploy.md. Do not disable pnpm's trust policy.
7. Routing
| When the task involves | Read |
|---|---|
Handler options, CORS, Origin/Host policy, responseMode, notifications, elicitation (MRTR), serving a sessionful legacy lane, app state in a Durable Object, KV or D1, Code Mode (search + execute) |
references/handler.md |
| OAuth provider config, identity, scopes, consent/CSRF, CIMD vs DCR, Claude or ChatGPT connector requirements | references/auth.md |
Existing McpAgent, SSE, SDK v1, createLegacyMcpHandler or provider 0.x code; any stale pattern not in section 4 |
references/migrate.md |
| Probes, vitest plugin, test harness, Inspector, deploy, custom domains, observability, rate limits | references/testing-deploy.md |
| Protocol revisions, which era a client speaks, the compatibility matrix, the MCP auth model | references/spec.md |
| Starting a new project | assets/stateless-server/: src/index.ts, src/server.ts, wrangler.jsonc, tsconfig.json, test/mcp.test.mjs and the probe (section 6); README.md has the steps |
| Re-verifying this skill against upstream | references/refresh.md (drives scripts/delta.py, sources.json and tests/) |
Re-verify any Cloudflare page by appending index.md to its URL for markdown. The MCP page index is https://developers.cloudflare.com/agents/llms.txt.
Keeping this skill current
Version literals and upstream claims here are snapshots from 2026-09-28.
To refresh, follow references/refresh.md: run scripts/delta.py to list upstream changes since the watermarks in sources.json, review them, then bump the watermarks from the reviewed report.
evals/ (evals.json plus fixtures/) holds the cases for writing-skills' run_evals.py.