跳到內容

ch37-agents

取得並執行

這個範例可以獨立 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.

Terminal window
npm install
npm run dev
RouteWhat it shows
GET /counterA working Agent using onStateChanged, plus proof that this.sql is synchronous
GET /probe/both-hooksOverriding both state hooks — supposed to throw at construction, does not locally
GET /probe/old-hookOverriding only the deprecated hook — supposed to warn, does not locally
GET /probe/protoThe same class prototype seen from the Worker instead of from inside the DO
GET /probe/moved-modulesagents/ai-chat-agent and agents/ai-react failing at load time
POST /mcpStateless MCP server with allowedHostnames set
POST /mcp-openThe 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) is Wrapper.prototype, not your class’s prototype. Own-property checks against it see only constructor, __miniflare_getDOName, __miniflare_introspectSqlite.
  • Inherited lookups still resolve normally — which is why the onStateChanged hook itself still fires (/counter logs it). The SDK’s mode detection uses proto.onStateChanged !== Agent.prototype.onStateChanged, an inherited lookup, so it survives.
  • this.constructor.name still reads correctly, because Miniflare copies the name onto Wrapper. But this.constructor === MyClass is 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, toolInputSchemaJson

which 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... -> 403
POST /mcp-open Host: evil.example.net -> 200 (tools/list returns normally)

The option’s doc comment explains the default:

Restrict Host headers to these hostnames. Localhost and workers.dev endpoints 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:

Terminal window
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.

  • nodejs_compat is required (agents imports node:async_hooks).
  • new_sqlite_classes, never new_classes — agent state lives in DO SQLite. --dry-run does 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.ts

tsconfig.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"]
}