ch37-agents
對應 37. Agents SDK 與 MCP server on Workers
在 GitHub 上檢視·7 個檔案·13.2 KB
取得並執行
這個範例可以獨立 clone 執行,不依賴其他章節。
npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch37-agents
cd ch37-agents
npm install可用指令
npm run dev # wrangler dev
npm run types # wrangler types --env-interface CloudflareBindings說明
ch37-agents — Agents SDK and MCP server on Workers
Section titled “ch37-agents — Agents SDK and MCP server on Workers”Companion example for chapter 37. Everything below was measured against
agents@0.20.1, @modelcontextprotocol/server@2.0.0,
@modelcontextprotocol/sdk@1.30.0, @cloudflare/ai-chat@0.10.1,
wrangler 4.118.0, compatibility date 2026-07-24, on 2026-08-01.
npm installnpm run devRoutes
Section titled “Routes”| Route | What it shows |
|---|---|
GET /counter | A working Agent using onStateChanged, plus proof that this.sql is synchronous |
GET /probe/both-hooks | Overriding both state hooks — supposed to throw at construction, does not locally |
GET /probe/old-hook | Overriding only the deprecated hook — supposed to warn, does not locally |
GET /probe/proto | The same class prototype seen from the Worker instead of from inside the DO |
GET /probe/moved-modules | agents/ai-chat-agent and agents/ai-react failing at load time |
POST /mcp | Stateless MCP server with allowedHostnames set |
POST /mcp-open | The same server with allowedHostnames unset |
Finding 1 — Miniflare’s DO wrapper silently disables the Agents SDK’s own guards
Section titled “Finding 1 — Miniflare’s DO wrapper silently disables the Agents SDK’s own guards”agents guards against overriding both onStateChanged and the deprecated
onStateUpdate:
const proto = Object.getPrototypeOf(this);const hasOwnNew = Object.prototype.hasOwnProperty.call(proto, "onStateChanged");const hasOwnOld = Object.prototype.hasOwnProperty.call(proto, "onStateUpdate");if (hasOwnNew && hasOwnOld) throw new Error("[Agent] Cannot override both ...");if (hasOwnOld) { /* WeakSet-deduped console.warn */ }BothHooksAgent in src/agents.ts overrides both. Under wrangler dev the
request succeeds:
$ curl localhost:8787/probe/both-hooks{"ok":"status=200 body={\"ctorName\":\"BothHooksAgent\", \"protoOwn\":[\"constructor\",\"__miniflare_getDOName\",\"__miniflare_introspectSqlite\"], \"hasOwnNew\":false,\"hasOwnOld\":false}"}Compare the same prototype seen from the Worker:
$ curl localhost:8787/probe/proto{"seenFrom":"worker","ownProps":["constructor","onStateChanged","onStateUpdate","onRequest"], "hasOwnNew":true,"hasOwnOld":true}The cause is in
node_modules/miniflare/dist/src/workers/core/do-wrapper.worker.js:
function createDurableObjectWrapper(UserClass) { class Wrapper extends UserClass { constructor(ctx, env) { /* records ctx.id.name into a __miniflare_do_name table */ } [GET_DO_NAME_METHOD]() { ... } [INTROSPECT_SQLITE_METHOD](queries) { ... } } return Object.defineProperty(Wrapper, "name", { value: UserClass.name }), Wrapper;}Miniflare subclasses every Durable Object class so the Local Explorer can
name instances and read their SQLite. Consequences inside a DO under
wrangler dev:
Object.getPrototypeOf(this)isWrapper.prototype, not your class’s prototype. Own-property checks against it see onlyconstructor,__miniflare_getDOName,__miniflare_introspectSqlite.- Inherited lookups still resolve normally — which is why the
onStateChangedhook itself still fires (/counterlogs it). The SDK’s mode detection usesproto.onStateChanged !== Agent.prototype.onStateChanged, an inherited lookup, so it survives. this.constructor.namestill reads correctly, because Miniflare copies the name ontoWrapper. Butthis.constructor === MyClassis false.
This inverts the usual local-vs-production asymmetry. Normally local dev is
the permissive one and production enforces more. Here a library’s own
runtime assertion is disabled locally, so the misconfiguration ships and
throws on deploy instead of at wrangler dev.
Generalised: inside a Durable Object, hasOwnProperty on
Object.getPrototypeOf(this) is not portable between wrangler dev and
production. Any decorator, DI container, or framework that registers methods
that way is silently inert locally. (That production throws is an inference
from the wrapper being Miniflare-only — it was not verified against a real
deployment.)
TypeScript does not help either: onStateUpdate is still on the Agent type
surface, so overriding both compiles cleanly.
Finding 2 — createMcpHandler is two overloads, and the good one needs a different package
Section titled “Finding 2 — createMcpHandler is two overloads, and the good one needs a different package”/** @deprecated Passing an SDK v1 server to createMcpHandler is deprecated ... */declare function createMcpHandler(server: McpServer_v1 | Server_v1, options?): LegacyMcpHandler;declare function createMcpHandler(factory: McpServerFactory, options?): StatelessMcpHandler;Passing a server instance still compiles. It just quietly returns the sessionful legacy handler instead of the stateless one. The type system steers, it does not stop you — read the deprecation, not the squiggle.
McpServerFactory is imported from @modelcontextprotocol/server — MCP SDK
v2, a different npm package from @modelcontextprotocol/sdk (v1, still at
1.30.0). agents@0.20.1 peer-depends on both, plus
@modelcontextprotocol/client@2.0.0. If you write the factory with a v1
McpServer you get:
Type 'McpServer' is missing the following properties from type 'McpServer': _toolInputSchemaJson, toolInputSchemaJsonwhich is the v1/v2 mismatch wearing a confusing costume.
Migration is not just “wrap it in an arrow function”: SDK v2 removed the
server.tool(name, schema, cb) shorthand. registerTool(name, config, cb) is
the only form, and it takes description, inputSchema, outputSchema,
annotations, icons, _meta.
@modelcontextprotocol/server also exports its own createMcpHandler, which
is not the one from agents/mcp. Import deliberately.
The factory signature is (ctx: McpRequestContext) => McpServer | Server | Promise<...> and it runs once per request. That is the point: isolates are
reused across requests, so a module-scope singleton leaks MCP session state
between users.
Finding 3 — DNS-rebinding protection, measured
Section titled “Finding 3 — DNS-rebinding protection, measured”/mcp sets allowedHostnames. /mcp-open does not. Same server otherwise.
POST /mcp Host: evil.example.net -> 403 {"error":{"code":-32000,"message":"Invalid Host: evil.example.net"}}POST /mcp Origin: https://evil... -> 403POST /mcp-open Host: evil.example.net -> 200 (tools/list returns normally)The option’s doc comment explains the default:
Restrict
Hostheaders to these hostnames. Localhost andworkers.devendpoints receive matching defaults; custom domains rely on Cloudflare routing unless this option is set.
So a workers.dev deployment is protected out of the box and a custom-domain
deployment is not. Set allowedHostnames explicitly.
allowedOriginHostnames: "*" exists; its own doc comment says to use it “only
when equivalent Origin validation runs in trusted middleware upstream”.
Finding 4 — the moved chat modules fail at load, not at call
Section titled “Finding 4 — the moved chat modules fail at load, not at call”$ curl localhost:8787/probe/moved-modules{ "agents/ai-chat-agent": {"threwName":"Error","threw":"All the AI Chat related modules are now in @cloudflare/ai-chat. ... Please use @cloudflare/ai-chat instead."}, "agents/ai-react": {"threwName":"Error","threw":"... Please use @cloudflare/ai-chat/ai-react instead."}, "@cloudflare/ai-chat/react": {"ok":"detectToolsRequiringConfirmation,extractClientToolSchemas,getAgentMessages,getToolApproval,getToolCallId,getToolInput,getToolOutput,getToolPartState,useAgentChat"}}Both module bodies are a bare throw — there is no export to feature-detect.
And the second error message is wrong: @cloudflare/ai-chat/ai-react is not
an exported subpath (ERR_PACKAGE_PATH_NOT_EXPORTED). The real one is
@cloudflare/ai-chat/react.
@cloudflare/ai-chat@0.10.1 exports ., ./react, ./types,
./ai-chat-v5-migration.
Finding 5 — the MCP server does work locally
Section titled “Finding 5 — the MCP server does work locally”Unlike most bindings in this series, this one is fully exercisable offline:
curl -sX POST localhost:8787/mcp \ -H 'content-type: application/json' \ -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'returns both tools with JSON Schema generated from the Zod shapes. Calling
whoami without OAuth in front returns {"hasAuthContext":false,"props":null}
— getMcpAuthContext() is undefined, not a throwing stub.
Configuration notes
Section titled “Configuration notes”nodejs_compatis required (agentsimportsnode:async_hooks).new_sqlite_classes, nevernew_classes— agent state lives in DO SQLite.--dry-rundoes not catch this (ch29).- If the Worker also serves assets, add
"run_worker_first": ["/agents/*"]or the SPA fallback swallows agent routes (ch07).
原始碼
wrangler.jsonc
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "ch37-agents",
"main": "src/index.ts",
"compatibility_date": "2026-07-24",
"compatibility_flags": ["nodejs_compat"],
"observability": { "enabled": true },
// Agent extends Server extends DurableObject -- so it is configured
// exactly like any other DO (ch14-17).
"durable_objects": {
"bindings": [
{ "name": "COUNTER", "class_name": "CounterAgent" },
{ "name": "BOTH_HOOKS", "class_name": "BothHooksAgent" },
{ "name": "OLD_HOOK", "class_name": "OldHookAgent" }
]
},
// NOT "new_classes". Agent state lives in the DO's SQLite storage,
// so a non-SQLite class fails at runtime -- and --dry-run does not
// catch it (see ch29).
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["CounterAgent", "BothHooksAgent", "OldHookAgent"]
}
]
}package.json
{
"name": "ch37-agents",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"types": "wrangler types --env-interface CloudflareBindings"
},
"dependencies": {
"@cloudflare/ai-chat": "^0.10.1",
"@modelcontextprotocol/sdk": "^1.30.0",
"@modelcontextprotocol/server": "^2.0.0",
"agents": "^0.20.1",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/node": "^26.1.2",
"typescript": "^5.9.2",
"wrangler": "^4.118.0"
}
}src/agents.ts
import { Agent } from "agents";
export type CounterState = { turns: number; last: string | null };
/**
* The correct modern shape: `onStateChanged` on the server.
*
* Note the asymmetry documented in 37.2 -- the *client* option in
* `useAgent({ onStateUpdate })` kept the OLD name. Only the server hook
* was renamed (agents 0.4.0).
*/
export class CounterAgent extends Agent<Env, CounterState> {
initialState: CounterState = { turns: 0, last: null };
onStateChanged(state: CounterState, source: unknown) {
console.log("[CounterAgent] onStateChanged", state.turns, typeof source);
}
async onRequest(req: Request) {
const url = new URL(req.url);
this.setState({ turns: this.state.turns + 1, last: url.pathname });
// `this.sql` is SYNCHRONOUS -- same as ctx.storage.sql.exec() in ch15.
// No await. It returns rows, not a promise.
const rows = this.sql<{ n: number }>`select 1 as n`;
return Response.json({
agent: "CounterAgent",
state: this.state,
sqlIsSync: !(rows instanceof Promise),
rows,
});
}
}
/**
* Overriding BOTH hooks. The Agent constructor is supposed to throw:
*
* const proto = Object.getPrototypeOf(this);
* if (hasOwnProperty(proto, "onStateChanged") &&
* hasOwnProperty(proto, "onStateUpdate")) throw ...
*
* It does NOT throw under `wrangler dev`. Miniflare wraps every Durable
* Object class (`class Wrapper extends UserClass`) to record the instance
* name and expose SQLite introspection, so `Object.getPrototypeOf(this)` is
* Wrapper.prototype -- whose own properties are `constructor`,
* `__miniflare_getDOName`, `__miniflare_introspectSqlite`. Your methods are
* one level further up the chain, so the hasOwnProperty guard sees nothing.
*
* `onRequest` below dumps that prototype so you can see it.
*/
export class BothHooksAgent extends Agent<Env, CounterState> {
initialState: CounterState = { turns: 0, last: null };
onStateChanged(_state: CounterState) {}
// NOTE: TypeScript does NOT flag this. `onStateUpdate` is still in the
// type surface -- the failure is purely runtime, at construction.
onStateUpdate(_state: CounterState) {}
async onRequest() {
const proto = Object.getPrototypeOf(this) as object;
return Response.json({
ctorName: this.constructor.name,
protoOwn: Object.getOwnPropertyNames(proto),
hasOwnNew: Object.prototype.hasOwnProperty.call(proto, "onStateChanged"),
hasOwnOld: Object.prototype.hasOwnProperty.call(proto, "onStateUpdate"),
});
}
}
/**
* Only the OLD hook. This is meant to warn (once per class, deduped through
* a module-level WeakSet). Locally it does not warn either -- same
* Miniflare wrapper, same dead hasOwnProperty check.
*
* The hook itself still FIRES, though: the mode detection next to the guard
* uses `proto.onStateChanged !== Agent.prototype.onStateChanged`, an
* inherited lookup, which walks the chain and finds your method. Own-property
* checks break under the wrapper; inherited lookups do not.
*/
export class OldHookAgent extends Agent<Env, CounterState> {
initialState: CounterState = { turns: 0, last: null };
// Again: no type error. Only a runtime console.warn.
onStateUpdate(_state: CounterState) {}
async onRequest() {
this.setState({ turns: this.state.turns + 1, last: "old" });
return Response.json({ agent: "OldHookAgent", state: this.state });
}
}src/index.ts
import { mcp, mcpOpen } from "./mcp";
export { CounterAgent, BothHooksAgent, OldHookAgent } from "./agents";
const t = async (fn: () => unknown): Promise<Record<string, unknown>> => {
const started = Date.now();
try {
const v = await fn();
return { ok: v === undefined ? "(undefined)" : v, ms: Date.now() - started };
} catch (e) {
const err = e as Error & { code?: number };
return {
threwName: err.name,
code: err.code,
threw: String(err.message ?? e).slice(0, 300),
ms: Date.now() - started,
};
}
};
export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(req.url);
// ---- MCP endpoint -------------------------------------------------
// StatelessMcpHandler is callable as (request, env, ctx) -- the same
// shape as a fetch handler. In production this sits behind OAuthProvider
// as `apiHandler`, and the verified identity arrives in `props`, which is
// what getMcpAuthContext().props reads.
if (url.pathname.startsWith("/mcp-open")) return mcpOpen(req, env, ctx);
if (url.pathname.startsWith("/mcp")) return mcp(req, env, ctx);
// ---- Working agent ------------------------------------------------
if (url.pathname === "/counter") {
const id = env.COUNTER.idFromName("demo");
return env.COUNTER.get(id).fetch(req);
}
// ---- Probe: both hooks overridden ----------------------------------
// The Agent constructor is supposed to throw here. Under `wrangler dev`
// it does NOT -- see the prototype dump in the response body and the
// README for why.
if (url.pathname === "/probe/both-hooks") {
const r = await t(async () => {
const id = env.BOTH_HOOKS.idFromName("demo");
const res = await env.BOTH_HOOKS.get(id).fetch(req);
return `status=${res.status} body=${(await res.text()).slice(0, 300)}`;
});
return Response.json(r);
}
// ---- Probe: only the old hook -> should warn ------------------------
// Also does not warn locally, for exactly the same reason.
if (url.pathname === "/probe/old-hook") {
const r = await t(async () => {
const id = env.OLD_HOOK.idFromName("demo");
const res = await env.OLD_HOOK.get(id).fetch(req);
return await res.json();
});
return Response.json(r);
}
// ---- Probe: what the class prototype ACTUALLY looks like ------------
// Compare /probe/proto (the real class, seen from the Worker) with the
// `protoOwn` field returned by /probe/both-hooks (the same class, seen
// from inside the Durable Object). They differ under `wrangler dev`.
if (url.pathname === "/probe/proto") {
const { BothHooksAgent } = await import("./agents");
const proto = BothHooksAgent.prototype as object;
return Response.json({
seenFrom: "worker",
ownProps: Object.getOwnPropertyNames(proto),
hasOwnNew: Object.prototype.hasOwnProperty.call(proto, "onStateChanged"),
hasOwnOld: Object.prototype.hasOwnProperty.call(proto, "onStateUpdate"),
});
}
// ---- Probe: the two throw-on-load modules ---------------------------
// These module bodies are literally a bare `throw`. The failure is at
// LOAD time, not call time, so there is nothing to feature-detect.
if (url.pathname === "/probe/moved-modules") {
return Response.json({
"agents/ai-chat-agent": await t(() => import("agents/ai-chat-agent")),
"agents/ai-react": await t(() => import("agents/ai-react")),
// The ai-react error text points at "@cloudflare/ai-chat/ai-react",
// which does not resolve. The real subpath is "/react".
"@cloudflare/ai-chat/react": await t(async () => {
const m = await import("@cloudflare/ai-chat/react");
return Object.keys(m).slice(0, 12).join(",");
}),
});
}
return new Response(
[
"ch37-agents probes:",
" GET /counter working Agent (onStateChanged)",
" GET /probe/both-hooks both hooks overridden -- expected to throw, does not",
" GET /probe/old-hook old hook only -- expected to warn, does not",
" GET /probe/proto the same prototype seen from the Worker",
" GET /probe/moved-modules throw-on-load modules",
" ANY /mcp MCP server, allowedHostnames set",
" ANY /mcp-open same server, allowedHostnames unset (forged Host is accepted)",
].join("\n"),
{ headers: { "content-type": "text/plain" } },
);
},
} satisfies ExportedHandler<Env>;src/mcp.ts
import { createMcpHandler, getMcpAuthContext } from "agents/mcp";
// NOTE THE PACKAGE. The non-deprecated path wants MCP SDK **v2**, which
// lives in `@modelcontextprotocol/server`, NOT in `@modelcontextprotocol/sdk`
// (which is v1, still at 1.30.0). `agents` peer-depends on both.
import { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";
/**
* `createMcpHandler` has TWO overloads:
*
* 1. (server: McpServer_v1 | Server_v1, opts?) => LegacyMcpHandler @deprecated
* 2. (factory: McpServerFactory, opts?) => StatelessMcpHandler
*
* So passing an instance still COMPILES -- it just silently gives you the
* sessionful legacy handler instead of the stateless one. The type system
* steers you, it does not stop you. Read the deprecation, not the red squiggle.
*
* The factory receives a per-request `McpRequestContext`, and is called once
* per request. That is what makes module-scope singletons wrong: isolates are
* reused across requests, so a shared server leaks MCP session state between
* users (MCP SDK >= 1.26).
*/
export const mcp = createMcpHandler(
(_ctx) => {
const server = new McpServer({ name: "ch37-demo", version: "1.0.0" });
// SDK v2 removed the `server.tool(name, schema, cb)` shorthand.
// `registerTool(name, config, cb)` is the only form.
server.registerTool(
"whoami",
{
description: "Return the authenticated principal from the auth context.",
inputSchema: {},
},
async () => {
// Identity comes from the auth context -- NEVER from a tool argument.
// Tool arguments are user-input-grade data: an LLM can be talked into
// passing someone else's tenant id (ch33).
const auth = getMcpAuthContext();
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
hasAuthContext: auth !== undefined,
props: auth?.props ?? null,
}),
},
],
};
},
);
// One tool = one intent. Do not mirror REST CRUD endpoints one-to-one;
// every extra tool call is another round of LLM latency and another
// chance to get it wrong.
server.registerTool(
"retarget_link",
{
description: "Point an existing short link at a new URL and invalidate its cache.",
inputSchema: {
slug: z.string().regex(/^[a-z0-9-]{3,64}$/),
url: z.url(),
},
},
async ({ slug, url }) => ({
content: [{ type: "text" as const, text: `would retarget ${slug} -> ${url}` }],
}),
);
return server;
},
{
route: "/mcp",
// Default DNS-rebinding protection covers localhost and *.workers.dev
// ONLY. Custom domains must be listed explicitly -- the option's own doc
// comment says "custom domains rely on Cloudflare routing unless this
// option is set".
allowedHostnames: ["localhost", "127.0.0.1", "mcp.example.com"],
},
);
/**
* The same server with NO `allowedHostnames`, so you can measure the default.
* Localhost and *.workers.dev get matching defaults automatically; anything
* else is accepted, because the docs' position is that custom domains "rely
* on Cloudflare routing".
*/
export const mcpOpen = createMcpHandler(
() => {
const server = new McpServer({ name: "ch37-open", version: "1.0.0" });
server.registerTool("ping", { description: "ping", inputSchema: {} }, async () => ({
content: [{ type: "text" as const, text: "pong" }],
}));
return server;
},
{ route: "/mcp-open" },
);.gitignore
node_modules/
.wrangler/
worker-configuration.d.tstsconfig.json
{
"compilerOptions": {
"target": "esnext", "lib": ["esnext"], "module": "esnext",
"moduleResolution": "bundler", "types": ["./worker-configuration.d.ts", "node"],
"strict": true, "skipLibCheck": true, "noEmit": true, "isolatedModules": true
},
"include": ["src/**/*.ts", "worker-configuration.d.ts"]
}