Agents SDK 與 MCP server on Workers
前四章分別給了推論(34)、統一入口(35)、檢索(36),這一章把它們組成能自己行動的東西 —— 以及讓別人的 AI 助理能操作你的產品。
⚠️ 不要說 Agents SDK 是 GA。 版本是 0.20.1,文件裡沒有任何 GA / beta 宣告。0.x 的 minor 版就會破壞相容 —— 本章實測到的一個改名就是 0.4.0 發生的。釘死版本號。
37.1 Agent 就是一個 Durable Object
Section titled “37.1 Agent 就是一個 Durable Object”DurableObject → Server → Agentclass Agent<Env, State, Props> extends Server<Env, Props>,而 Server 繼承 DurableObject。
這代表第 14 到 17 章的所有知識直接適用:input gate 的序列化保證、per-instance 的 SQLite(第 15 章)、alarm(第 17 章)、WebSocket hibernation(第 16 章)。Agent 只是在上面加了一層狀態同步與工具呼叫的慣例。
設定上因此也繼承了 DO 的要求:
{ "compatibility_flags": ["nodejs_compat"], "durable_objects": { "bindings": [{ "name": "AGENT", "class_name": "MyAgent" }] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["MyAgent"] }]}new_sqlite_classes 不是可選的 —— agent 的狀態存在 DO 的 SQLite 裡。用 new_classes 會在執行期壞掉(第 29 章實測過,--dry-run 不會擋你)。
還有一個和第 7 章有關的坑:
{ "assets": { "directory": "./public", "run_worker_first": ["/agents/*"] } }如果你的 Worker 同時服務靜態資源,沒有 run_worker_first 的話 SPA 的 fallback 會吞掉 agent 路由。 第 7 章實測過 not_found_handling 的行為,這裡是它的具體後果。
export class MyAgent extends Agent<Env, MyState> { initialState: MyState = { turns: 0 };
async onRequest(req: Request) { this.setState({ ...this.state, turns: this.state.turns + 1 }); // this.sql 是同步的(第 15 章的 DO SQLite) const rows = this.sql`select * from notes where id = ${id}`; return Response.json({ state: this.state, rows }); }}this.sql 是同步的 —— 和第 15 章的 ctx.storage.sql.exec() 一樣,不需要 await。
37.2 頭號改名陷阱:onStateChanged vs onStateUpdate
Section titled “37.2 頭號改名陷阱:onStateChanged vs onStateUpdate”這是 0.4.0 的改名,而且兩個名字現在都活著,只是在不同的地方:
| 位置 | 名稱 |
|---|---|
| server 端(Agent 子類的 hook) | onStateChanged |
client 端(useAgent 的選項) | 仍然是 onStateUpdate |
實測 agents@0.20.1 的實作,建構時有一段防護:
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 onStateChanged and onStateUpdate. " + "Remove onStateUpdate — it has been renamed to onStateChanged.");if (hasOwnOld) { // 每個 class 只警告一次 console.warn("[Agent] onStateUpdate is deprecated. Rename to onStateChanged — " + "the behavior is identical.");}讀起來很清楚:兩個都覆寫會 throw,只覆寫舊的會拿到一次 deprecation 警告(用 WeakSet 去重,每個 class 只印一次)。
於是我照這個寫了一個兩個都覆寫的 BothHooksAgent 去撞它。
它沒有 throw。也沒有警告。請求 200 回來了。
🔴 為什麼沒 throw:Miniflare 會偷偷包一層 DO subclass
Section titled “🔴 為什麼沒 throw:Miniflare 會偷偷包一層 DO subclass”我讓那個 agent 在 onRequest 裡把自己的 prototype 印出來:
// 從 Durable Object 內部看{ "ctorName": "BothHooksAgent", "protoOwn": ["constructor", "__miniflare_getDOName", "__miniflare_introspectSqlite"], "hasOwnNew": false, "hasOwnOld": false}// 同一個 class,從 Worker 看{ "ownProps": ["constructor", "onStateChanged", "onStateUpdate", "onRequest"], "hasOwnNew": true, "hasOwnOld": true}原因在 node_modules/miniflare/dist/src/workers/core/do-wrapper.worker.js:
function createDurableObjectWrapper(UserClass) { class Wrapper extends UserClass { constructor(ctx, env) { /* 把 ctx.id.name 寫進 __miniflare_do_name 表 */ } [GET_DO_NAME_METHOD]() { /* ... */ } [INTROSPECT_SQLITE_METHOD](queries) { /* ... */ } } return Object.defineProperty(Wrapper, "name", { value: UserClass.name }), Wrapper;}wrangler dev 會把你的每一個 Durable Object class 再包一層 subclass,純粹是為了讓 Local Explorer 能拿到 instance 名稱、能讀它的 SQLite。後果是:在本機的 DO 裡,
Object.getPrototypeOf(this)是Wrapper.prototype,不是你的 class 的 prototype。對它做 own-property 檢查,只會看到constructor、__miniflare_getDOName、__miniflare_introspectSqlite。- 繼承查找(inherited lookup)完全正常 —— 所以 hook 本身還是會觸發(
/counter確實印出onStateChanged)。SDK 判斷走哪條路徑用的是proto.onStateChanged !== Agent.prototype.onStateChanged,那是繼承查找,活下來了。 this.constructor.name仍然正確(Miniflare 用defineProperty把名字複製到Wrapper上),但this.constructor === MyClass是false。
這一條的方向和本系列前面所有「本機測不出來」都相反。 平常是本機比較寬鬆、production 比較嚴格。這裡是函式庫自己寫的 runtime assertion 在本機被關掉了 —— 所以錯誤設定會順利通過
wrangler dev,然後在 deploy 之後才 throw。一般化:在 Durable Object 裡,對
Object.getPrototypeOf(this)做hasOwnProperty是不可攜的。 任何靠這個做註冊的 decorator、DI container、框架約定,在本機都是靜默失效的。(「production 會 throw」是推論:那層 wrapper 只存在於 Miniflare。我沒有真的部署去驗證。)
而 TypeScript 也擋不住你 —— onStateUpdate 還在 Agent 的型別表面上,兩個都寫編譯完全通過。
實作裡還有一個內部欄位 _persistenceHookMode("new" / "old"),就是上面那個靠繼承查找比對的結果。
37.3 Chat 已經搬套件,而且搬得很兇
Section titled “37.3 Chat 已經搬套件,而且搬得很兇”實測 agents@0.20.1 的 dist/ai-chat-agent.js,整個模組的內容就是一個 throw:
//#region src/ai-chat-agent.tsthrow new Error("All the AI Chat related modules are now in @cloudflare/ai-chat. " + "This module is deprecated and will be removed in the next major version. " + "Please use @cloudflare/ai-chat instead.");//#endregionexport {};dist/ai-react.js 也一樣。
注意這是「載入即 throw」,不是「呼叫才 throw」。 只要
import { AIChatAgent } from "agents/ai-chat-agent"出現在你的 bundle 裡,整個 Worker 就起不來。這是我看過最直接的棄用手段 —— 好處是不可能被忽略。
正確的 import:
import { AIChatAgent } from "@cloudflare/ai-chat";import { useAgentChat } from "@cloudflare/ai-chat/react";實測 @cloudflare/ai-chat@0.10.1 的 exports 是 .、./react、./types、./ai-chat-v5-migration。
⚠️ 而且那個錯誤訊息本身是錯的。
agents/ai-react的 throw 訊息說「Please use@cloudflare/ai-chat/ai-reactinstead」,但實測:@cloudflare/ai-chat/ai-react → ERR_PACKAGE_PATH_NOT_EXPORTED@cloudflare/ai-chat/react → 可解析正確的是
/react,不是/ai-react。 照著錯誤訊息做會撞到第二個錯誤。
37.4 MCP server:三選一
Section titled “37.4 MCP server:三選一”agents@0.20.1 提供三條路,選錯會讓你多寫很多不必要的東西。
| 方式 | 需要 DO? | 適合 |
|---|---|---|
createMcpHandler() | 否 | 新的預設。 無狀態的工具集 |
McpAgent | 是 | 需要 per-session 狀態、長時間對話 |
| raw transport | 是 | 需要完全控制傳輸層 |
createMcpHandler() 是新的預設
Section titled “createMcpHandler() 是新的預設”實測 export 的真身:
createStatelessMcpHandler as createMcpHandler名字裡的 Stateless 說明了一切。但 createMcpHandler 這個名字底下其實有兩個 overload:
/** @deprecated Passing an SDK v1 server to createMcpHandler is deprecated and * will be removed in the next major version. Pass an SDK v2 factory ... */declare function createMcpHandler( server: McpServer_v1 | Server_v1, options?: CreateMcpHandlerOptions,): LegacyMcpHandler;
declare function createMcpHandler( factory: McpServerFactory, options?: CreateStatelessMcpHandlerOptions,): StatelessMcpHandler;🔴 McpServer 必須 per-request 建構 —— 但型別不會幫你擋
Section titled “🔴 McpServer 必須 per-request 建構 —— 但型別不會幫你擋”我原本以為「參數型別是 McpServerFactory 不是 McpServer」等於型別層級強制。實測不是。 傳一個 instance 進去照樣編譯通過 —— 它只是安靜地走 overload 1,回給你有 session 的 legacy handler,不是你以為的 stateless handler。
型別在這裡只是引導,不是護欄。要看的是那行
@deprecated,不是編輯器有沒有畫紅線。
// ❌ 編譯得過,但你拿到的是 LegacyMcpHandler,而且是模組層 singletonconst server = new McpServer({ name: "linkforge", version: "1.0.0" });export default createMcpHandler(server);
// ✅ 每個請求一個新的 server,拿到 StatelessMcpHandlerexport default createMcpHandler((ctx) => { const server = new McpServer({ name: "linkforge", version: "1.0.0" }); server.registerTool("create_link", { description, inputSchema }, handler); return server;});模組層的 singleton 在 MCP SDK 1.26+ 是安全問題,因為 server 實例會累積 per-connection 的狀態,而 Workers 的 isolate 在多個請求之間重用(第 1 章、第 3 章)—— A 使用者的 session 狀態會被 B 使用者看到。這是第 34 章那個「模組層變數跨請求共享」在 MCP 語境下的具體危害。
🔴 而且「SDK v2」是另一個 npm 套件
Section titled “🔴 而且「SDK v2」是另一個 npm 套件”McpServerFactory 不是從 @modelcontextprotocol/sdk 來的:
import { McpServerFactory } from "@modelcontextprotocol/server";實測 agents@0.20.1 的 peer dependencies:
| 套件 | 版本 | 是什麼 |
|---|---|---|
@modelcontextprotocol/sdk | 1.30.0 | SDK v1(舊的,legacy overload 用) |
@modelcontextprotocol/server | 2.0.0 | SDK v2 server(factory overload 用) |
@modelcontextprotocol/client | 2.0.0 | SDK v2 client |
如果你用 v1 的 McpServer 去寫 factory,錯誤訊息長這樣:
Type 'McpServer' is not assignable to type 'McpServer | Server | Promise<...>'. Type 'McpServer' is missing the following properties from type 'McpServer': _toolInputSchemaJson, toolInputSchemaJson「McpServer 不能指派給 McpServer」 —— 這就是 v1/v2 撞名的樣子。看到這個訊息時要想到的是套件裝錯了,不是型別有 bug。
而且遷移不只是包一層箭頭函式:SDK v2 拿掉了 server.tool(name, schema, cb) 這個簡寫,只剩 registerTool(name, config, cb):
server.registerTool( "retarget_link", { description: "Point an existing short link at a new URL.", inputSchema: { slug: z.string(), url: z.url() }, // v2 的 config 原生支援 outputSchema / annotations / icons / _meta }, async ({ slug, url }) => ({ content: [{ type: "text", text: `ok ${slug} ${url}` }] }),);還有一個撞名要小心:
@modelcontextprotocol/server自己也 export 一個createMcpHandler,那不是agents/mcp的那一個。import 的來源要看清楚。
factory 的完整型別是 (ctx: McpRequestContext) => McpServer | Server | Promise<...>,每個請求呼叫一次。
CreateStatelessMcpHandlerOptions 裡的安全預設
Section titled “CreateStatelessMcpHandlerOptions 裡的安全預設”實測型別,有兩個很值得知道的欄位:
interface CreateStatelessMcpHandlerOptions { route?: string; // 預設 "/mcp" corsOptions?: CORSOptions | false; allowedHostnames?: string[]; allowedOriginHostnames?: string[] | "*"; authContext?: McpAuthContext;}型別註解寫得很清楚:
allowedHostnames:「RestrictHostheaders to these hostnames. Localhost andworkers.devendpoints receive matching defaults; custom domains rely on Cloudflare routing unless this option is set.」
allowedOriginHostnames:「Restrict present browserOriginheaders to these hostnames. Requests without an Origin (including non-browser MCP clients) remain valid… Pass"*"only when equivalent Origin validation runs in trusted middleware upstream.」
用自訂網域時要自己設 allowedHostnames —— 預設只保護 localhost 與 workers.dev。這是 DNS rebinding 防護,很容易漏。
這一條本機就量得出來。範例裡掛了兩個一模一樣的 server,一個有設 allowedHostnames(/mcp),一個沒設(/mcp-open):
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 正常回傳沒設的那個,偽造 Host 直接放行。
getMcpAuthContext() 是在 tool handler 裡取得認證資訊的管道({ props: Record<string, unknown> })。沒有 OAuth 在前面時它回 undefined —— 是乾淨的 undefined,不是會 throw 的 stub(本系列前面遇過六次的那種)。
好消息:MCP server 是本系列少數「本機可以完整跑起來」的東西。
initialize、tools/list、tools/call全部走得通,Zod shape 也會被正確轉成 JSON Schema。不需要任何 Cloudflare 帳號。
37.5 OAuth
Section titled “37.5 OAuth”@cloudflare/workers-oauth-provider — 實測最新版 0.8.3務必升過 0.8.0。 0.8.0 修了 cross-client token revocation 與 authorization code exchange 的缺陷。低於這個版本的專案應該視為有已知漏洞。
一個必須手動設定的項目:
new OAuthProvider({ // ... allowPlainPKCE: false, // 預設是 true});預設允許 plain PKCE(code_challenge_method=plain),那基本上等於沒有 PKCE。除非你要相容某個很舊的 client,一律設 false。
37.6 2026 新增的能力
Section titled “37.6 2026 新增的能力”agents@0.20.1 的 exports 有 33 個 subpath,其中幾個是 2026 才有的:
| Subpath | 是什麼 |
|---|---|
agents/workflows | Fibers —— durable execution,runFiber / ctx.stash() |
agents/skills、agents/skills/compile | Agent Skills |
agents/codemode/ai | Code Mode |
agents/x402 | 付款協定 |
agents/experimental/memory/session | 記憶體(experimental) |
agents/experimental/webmcp | 瀏覽器端 MCP(experimental) |
agents/browser、agents/browser/ai | 接第 30 章的 Browser Run |
agents/email | 接第 32 章的 Email |
agents/observability、agents/observability/ai | 可觀測性 |
注意有兩個掛在 experimental/ 底下 —— 在 0.x 的套件裡再標 experimental,等於「隨時會消失」。
Fibers 與 human-in-the-loop
Section titled “Fibers 與 human-in-the-loop”waitForApproval() 由第 21 章的 Workflows 支撐,所以它可以等好幾個月 —— 第 21 章實測過 waitForEvent 的 timeout 上限是 365 天。
這是把第 21 章的 durable execution 用在對的地方:agent 決定要做一件需要人類批准的事 → 暫停 → 幾天後主管按下批准 → agent 從中斷處繼續,而且中間不佔用任何運算資源(第 21 章:waiting 狀態不計 concurrency、不計 CPU)。
Sub-agents 用 DO Facets
Section titled “Sub-agents 用 DO Facets”第 33 章的 DO Facets 在這裡有了具體用途:只有 parent agent 需要 DO binding,sub-agent 是它的 facet,各自有隔離的 SQLite。
Code Mode
Section titled “Code Mode”@cloudflare/codemode — 實測最新版 0.5.1(beta)把 MCP tools 轉成 TypeScript API,讓 LLM 寫程式碼去呼叫,而不是一次一個 tool call。需要 worker_loaders binding(第 33 章)—— 因為 LLM 產生的程式碼要被載入執行。
第 33 章那些關於 globalOutbound: null、env 能力邊界、limits 本機不生效的結論全部適用。Code Mode 就是那一章的沙箱在跑 LLM 寫的程式碼。
37.7 不要教的東西
Section titled “37.7 不要教的東西”這一節專門糾正搜尋結果裡的雜訊。
| 東西 | 狀態 |
|---|---|
workers-mcp | 2025-03 之後已死。 不要用 |
mcp-handler | 那是 Vercel 的套件,和 Cloudflare 無關,純粹撞名 |
| MCP sampling | 文件裡沒有 |
| MCP tool annotations | 文件裡沒有 |
MCP outputSchema | 文件裡沒有 |
還有一個很小但會浪費你半小時的:
MCP dev server 的預設埠是
8788,不是8787。
以及第 35 章提過的名字撞車,這裡再強調一次:
Cloudflare 的 MCP Server Portals 是 Cloudflare One / Zero Trust 的功能,和 Agents SDK 的 MCP server 完全是兩回事。 搜尋「Cloudflare MCP」會同時撈到兩者。
37.8 LinkForge:一個受 OAuth 保護的 MCP server
Section titled “37.8 LinkForge:一個受 OAuth 保護的 MCP server”目標:讓使用者的 AI 助理(Claude、Cursor…)能管理他們的短網址。
import { createMcpHandler, getMcpAuthContext } from "agents/mcp";// SDK v2,不是 @modelcontextprotocol/sdk(37.4)import { McpServer } from "@modelcontextprotocol/server";import { z } from "zod";
export const mcp = createMcpHandler( // 工廠函式:每個請求一個新的 server(37.4) () => { const server = new McpServer({ name: "linkforge", version: "1.0.0" });
server.registerTool( "list_links", { description: "List the caller's short links.", inputSchema: { limit: z.number().int().min(1).max(50).default(20) }, }, async ({ limit }) => { // 認證資訊從 auth context 拿,絕不從工具參數拿 const auth = getMcpAuthContext(); const tenantId = auth?.props.tenantId as string | undefined; if (!tenantId) throw new Error("unauthenticated");
const { results } = await env.DB .prepare("select slug, url, clicks from links where tenant_id = ?1 order by created_at desc limit ?2") .bind(tenantId, limit).all(); return { content: [{ type: "text", text: JSON.stringify(results) }] }; }, );
server.registerTool( "create_link", { description: "Create a short link.", inputSchema: { url: z.url(), slug: z.string().regex(/^[a-z0-9-]{3,64}$/).optional() }, }, async ({ url, slug }) => { const tenantId = getMcpAuthContext()?.props.tenantId as string; if (!tenantId) throw new Error("unauthenticated"); const created = await createLink(env, tenantId, url, slug); return { content: [{ type: "text", text: `Created ${created.slug}` }] }; }, );
return server; }, { route: "/mcp", // 自訂網域必須自己設(37.4) allowedHostnames: ["mcp.linkforge.dev"], },);三個安全要點,每一個都對應本章或前面章節的實測:
tenantId從getMcpAuthContext()拿,絕不從工具參數拿。 如果list_links有一個tenantId參數,LLM 就能被 prompt injection 說服去傳別人的租戶 ID。工具參數是使用者輸入等級的資料(第 33 章的結論)。- 工廠函式,不是 singleton。 第 37.4。
allowedHostnames明確列出自訂網域。 型別註解說得很清楚,預設不保護自訂網域。
搭配 OAuth:
import OAuthProvider from "@cloudflare/workers-oauth-provider";import { mcp } from "./mcp";
export default new OAuthProvider({ apiRoute: "/mcp", apiHandler: mcp, defaultHandler: app, // 你的 Hono app(第 5 章)處理登入頁 authorizeEndpoint: "/oauth/authorize", tokenEndpoint: "/oauth/token", clientRegistrationEndpoint: "/oauth/register", allowPlainPKCE: false, // 37.5:預設是 true});OAuthProvider 會把驗證後的資訊放進 props,而那正是 getMcpAuthContext().props 讀到的東西 —— 這條鏈就是租戶隔離的全部。
工具設計的一個原則
Section titled “工具設計的一個原則”MCP 工具的粒度要比 REST API 粗。 不要把你的 CRUD 端點一比一對應成工具 —— LLM 每多一個 tool call 就是一次延遲加一次出錯機會。
// ❌ 三個工具,LLM 要串起來"get_link" / "update_link_url" / "invalidate_link_cache"
// ✅ 一個工具表達一個意圖"retarget_link" // 內部自己處理更新 + 失效這和第 18 章 RPC 那條「promise pipelining 減少 round trip」是同一個直覺,只是這裡的 round trip 是 LLM 的推論成本。
37.9 本章實測結論彙整
Section titled “37.9 本章實測結論彙整”| # | 結論 | 影響 |
|---|---|---|
| 1 | agents 實測版本 0.20.1,文件無 GA/beta 宣告 | 不要說它 GA;釘死版本 |
| 2 | 繼承鏈 DurableObject → Server → Agent | 第 14-17 章的知識直接複用 |
| 3 | 必須用 new_sqlite_classes migration + nodejs_compat | 用 new_classes 執行期才壞(第 29 章:dry-run 不擋) |
| 4 | 有靜態資源時要 run_worker_first: ["/agents/*"] | 否則 SPA fallback 吞掉 agent 路由(第 7 章) |
| 5 | server 端 hook 是 onStateChanged;client 端 useAgent 選項仍是 onStateUpdate | 兩個名字都活著,在不同地方 |
| 6 | 原始碼裡兩個都覆寫應該 throw(Cannot override both onStateChanged and onStateUpdate),只覆寫舊的應該警告 | 但見下面兩條 |
| 6a | 🔴 實測:wrangler dev 下兩者都不會發生。 Miniflare 用 class Wrapper extends UserClass 包住每個 DO class,Object.getPrototypeOf(this) 變成 Wrapper.prototype | 函式庫自己的 assertion 在本機被關掉,錯誤設定會一路過關到 deploy |
| 6b | 一般化:DO 裡對 Object.getPrototypeOf(this) 做 hasOwnProperty 不可攜;繼承查找則正常 | 靠 own-property 註冊的 decorator / DI 在本機靜默失效 |
| 6c | this.constructor.name 仍正確(Miniflare defineProperty 複製名字),但 this.constructor === MyClass 為 false | 名字可信,identity 不可信 |
| 7 | TypeScript 不會標記 onStateUpdate —— 它還在型別表面上 | 兩個都寫編譯完全通過 |
| 8 | agents/ai-chat-agent 與 agents/ai-react 的模組內容就是一個 throw | 載入即失敗,不是呼叫才失敗 |
| 9 | agents/ai-react 的錯誤訊息指向 @cloudflare/ai-chat/ai-react,那個路徑不存在 | 實測正確的是 /react |
| 10 | @cloudflare/ai-chat@0.10.1 的 exports:.、./react、./types、./ai-chat-v5-migration | |
| 11 | createMcpHandler 的真身是 createStatelessMcpHandler | 名字裡就寫了 Stateless |
| 12 | 🔴 它有兩個 overload。傳 instance 照樣編譯過,只是安靜地回 LegacyMcpHandler(有 session 的舊路徑) | 型別只是引導不是護欄;要看 @deprecated |
| 12a | McpServerFactory 來自 @modelcontextprotocol/server(SDK v2),不是 @modelcontextprotocol/sdk(v1,1.30.0)。agents 兩個都 peer-depend | 裝錯套件的症狀是「McpServer 不能指派給 McpServer」 |
| 12b | SDK v2 移除了 server.tool() 簡寫,只剩 registerTool(name, config, cb)(config 支援 outputSchema/annotations/icons) | 遷移不只是包一層箭頭函式 |
| 12c | @modelcontextprotocol/server 自己也 export 一個 createMcpHandler | 又一個撞名 |
| 13 | 模組層 singleton 在 MCP SDK 1.26+ 是安全問題 | isolate 重用會讓 session 狀態跨使用者洩漏 |
| 14 | allowedHostnames 預設只保護 localhost 與 workers.dev,自訂網域要自己設 | DNS rebinding 防護 |
| 14a | 本機實測:有設 → 偽造 Host 得到 403 Invalid Host;沒設 → 200 放行 | 這條可以在 wrangler dev 直接量 |
| 15 | allowedOriginHostnames: "*" 型別註解明說「只在上游有等效驗證時使用」;偽造 Origin 實測 403 | |
| 15a | MCP server 是本系列少數本機可完整跑通的東西:initialize / tools/list / tools/call 全部可用,Zod → JSON Schema 正確 | 不需要 Cloudflare 帳號 |
| 15b | 沒有 OAuth 時 getMcpAuthContext() 回乾淨的 undefined | 不是會 throw 的 stub(對照第 6/23/25/27/30/32 章) |
| 16 | @cloudflare/workers-oauth-provider 實測 0.8.3;0.8.0 修了 token revocation 與 code exchange 缺陷 | 低於 0.8.0 視為有漏洞 |
| 17 | allowPlainPKCE 預設是 true | 一律設 false |
| 18 | agents 有 33 個 export subpath,含 workflows(Fibers)、skills、codemode/ai、x402、browser、email | 兩個掛在 experimental/ 底下 |
| 19 | waitForApproval() 由 Workflows 支撐,可等到 365 天 | 等待期間不計 concurrency 與 CPU(第 21 章) |
| 20 | Sub-agents 用 DO Facets,只有 parent 需要 binding | 第 33 章 |
| 21 | Code Mode(@cloudflare/codemode 0.5.1,beta)需要 worker_loaders | 第 33 章的沙箱結論全部適用 |
| 22 | workers-mcp 已死;mcp-handler 是 Vercel 的無關套件 | 純粹撞名 |
| 23 | MCP sampling 在 Cloudflare 文件裡沒有;但 outputSchema / annotations 是 SDK v2 registerTool config 原生支援的欄位 | 分清楚「Cloudflare 沒寫」與「SDK 沒有」 |
| 24 | MCP dev server 預設埠是 8788,不是 8787 | |
| 25 | MCP Server Portals 屬於 Cloudflare One / Zero Trust | 與本章無關(第 35 章也提過) |
37.10 動手練習
Section titled “37.10 動手練習”- 在一個 Agent 子類裡同時定義
onStateChanged與onStateUpdate,在wrangler dev下確認它不會 throw;再把Object.getPrototypeOf(this)的 own properties 印出來,看到那三個__miniflare_*名字。然後(如果你有帳號)部署上去,看它是不是真的 throw —— 這是本章唯一沒被驗證的推論。 - 承上:寫一個 DO base class,用
hasOwnProperty(Object.getPrototypeOf(this), ...)做方法註冊,確認本機完全失效。再改成繼承查找(proto.foo !== Base.prototype.foo),確認本機就正常了。 import "agents/ai-chat-agent"然後啟動 Worker,確認它在載入階段就失敗。再照著錯誤訊息 import@cloudflare/ai-chat/ai-react,觀察第二個錯誤。- 把
createMcpHandler(new McpServer(...))寫出來 —— 確認它編譯得過,然後用回傳型別(LegacyMcpHandlervsStatelessMcpHandler)證明你走到了 deprecated 的那條 overload。 - 在本機開兩個 MCP handler,一個設
allowedHostnames一個不設,用curl -H 'Host: evil.example.net'分別打,比對 403 與 200。 - 用
waitForApproval()寫一個需要人類批准的工具,把它擱置一天再批准,確認 agent 真的從中斷處繼續。
- Agents · Agents SDK API · MCP
@cloudflare/workers-oauth-provider· Model Context Protocol- 版本與 API 表面來源:
node_modules/agents@0.20.1(dist/index.js的 state hook 防護與_autoWrapCustomMethods、dist/ai-chat-agent.js的 throw、dist/agent-tool-types-*.d.ts的createMcpHandleroverload、dist/handler-stateless-*.d.ts的CreateStatelessMcpHandlerOptions)、node_modules/@modelcontextprotocol/server@2.0.0(McpServerFactory、McpServer.registerTool)、node_modules/@cloudflare/ai-chat@0.10.1的 exports、node_modules/miniflare/dist/src/workers/core/do-wrapper.worker.js(DO subclass wrapper)、npm registry 的版本資料 - 所有 HTTP 觀測值取自本機
wrangler dev(examples/ch37-agents),2026-08-01
第 37 章結束 Part 7。下一章起進入 Part 8 — 工程實務:第 38 章 測試,包含前面各章反覆遇到的那些「本機測不出來」的東西,到底該怎麼測。