Cloudflare Agents MCP 客户端能力实战:在原生 Durable Object 中组合 MCPClientManager
2026/9/18 21:41:49 网站建设 项目流程

Cloudflare Agents MCP 客户端能力实战:在原生 Durable Object 中组合 MCPClientManager

【免费下载链接】agentsBuild and deploy AI Agents on Cloudflare项目地址: https://gitcode.com/GitHub_Trending/agents1/agents

导读

本文基于开源仓库 agents1/agents(Cloudflare Agents 项目)中的官方示例 examples/next/mcp-client,完整讲解如何在不继承Agent基类的前提下,把一个普通的 CloudflareDurableObject通过生命周期(Lifecycle)能力机制装配成持久化的 MCP(Model Context Protocol)客户端:它能够接收任意 Streamable HTTP 形式的 MCP 端点、在弹窗中完成 OAuth 授权、持久化 HTTP 长连接并在对象被驱逐(eviction)后自动恢复,最终以 JSON 参数调用服务器发现的工具。读完本文,你将掌握MCPClientManager+Lifecycle.install()的组合模式、OAuth 回调路由的注册规则、Durable Object RPC 边界下的生命周期启动约定,以及完整的可复制 HTTP API 与前端交互实现。


一、示例概览:一个不依赖 Agent 基类的 MCP 客户端

仓库中的示例examples/next/mcp-client是一个 Vite + React 的前后端一体演示:Worker 入口把请求通过routeAgentRequest()路由到名为McpClientObject的 Durable Object,该对象内部只组合MCPClientManager这一生命周期能力,并没有继承Agent

其核心声明只有寥寥几行(见 src/server.ts):

export class McpClientObject extends DurableObject<Env> { readonly mcp = new MCPClientManager("mcp-client-object", "1.0.0"); readonly lifecycle = Lifecycle.install(this).use(this.mcp); ... }

从官方文档 docs/agents/mcp-client.md 可以确认,MCPClientManager本身就是一个lifecycle capability(生命周期能力):类可以直接继承平台DurableObject并安装该管理器,而不必扩展AgentAgent内部其实也是直接安装同一个能力对象,因此现有的this.mcpaddMcpServer()removeMcpServer()getMcpServers()等 API 在该框架中依然可用。

生命周期(Lifecycle)为管理器做了什么

Lifecycle.install(this).use(this.mcp)把管理器注册进宿主对象的生命周期,由生命周期自动完成三件关键工作:

  1. 初始化 Schema:在onStart()阶段初始化管理器的数据库表结构与持久化状态,之后宿主才开始处理工作;
  2. 恢复被驱逐后的 HTTP 连接:Durable Object 被逐出内存后,对象重新冷启动时生命周期会恢复此前持久化的 MCP 连接;
  3. 回调 URL 拦截:在宿主onRequest()之前,先拦截已注册的 OAuth 回调 URL,交给管理器完成 OAuth 流程。

因此示例中onStart()onRequest()两个钩子只做演示相关的收尾工作,MCP 相关的底层逻辑完全交给生命周期与管理器:

onStart(): void { configureOAuthPopup(this.mcp); } onRequest(request: Request): Promise<Response> { return handleDemoRequest(this.mcp, request); }

关键约定:不要手动调用生命周期钩子

官方文档明确警告:不要手动调用这些钩子。但存在一个例外场景——原生 Durable Object RPC 方法不走fetch,生命周期不会自动启动,因此 RPC 方法内部需要先显式await this.lifecycle.start()。本文第四节会结合getCatalog()详细展开这一点。


二、运行与部署

本地启动

进入示例目录执行:

pnpm install pnpm run start

其中start脚本定义为vite dev(见 package.json),由 Vite 同时承载前端与 Worker。

部署到 Cloudflare

pnpm run deploy # 等价于 vite build && wrangler deploy

示例还提供了pnpm run typechecktsc --noEmit)与pnpm run typeswrangler types env.d.ts --include-runtime false)两个辅助脚本。

wrangler 配置解读

示例的 wrangler.jsonc 是最小但完整的 Durable Object + 静态资源配置:

{ "name": "next-mcp-client-capability", "main": "src/server.ts", "compatibility_date": "2026-06-11", "compatibility_flags": ["nodejs_compat"], "assets": { "not_found_handling": "single-page-application", "run_worker_first": ["/agents/*"] }, "durable_objects": { "bindings": [ { "name": "McpClientObject", "class_name": "McpClientObject" } ] }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["McpClientObject"] } ], "observability": { "enabled": true } }
  • durable_objects.bindingsMcpClientObject类绑定到同名引用,Worker 入口用它创建/寻址 Durable Object;
  • migrations.new_sqlite_classes声明该 Durable Object 使用SQLite 存储——MCPClientManager的服务端元数据(server 列表、OAuth 回调 URL、client_id 等)正是持久化在 SQLite 表中(核心实现见 packages/agents/src/mcp/client/index.ts 中saveServerToStorage等存储相关方法);
  • assets配置把/agents/*的请求优先交给 Worker 处理(run_worker_first),其余路径回退到 SPA 静态资源(not_found_handling: "single-page-application")——这正是 MCP 对象 API 与 React 页面共存的配置基础。

三、HTTP API 设计:以请求路径驱动 OAuth 回调

完整路由表

示例对象暴露的 HTTP API 如下(来自 README.md):

GET /agents/mcp-client-object/:instance POST /agents/mcp-client-object/:instance/connect POST /agents/mcp-client-object/:instance/tools/call DELETE /agents/mcp-client-object/:instance/servers/:id

其中:instance是浏览器本地生成的随机对象名(详见第五节),它保证每个浏览器拥有互相隔离的服务器目录与 OAuth 凭据

connect:注册、连接与回调 URL 推导

connect路由在 demo-api.ts 中实现,是理解整套设计的关键:

const id = normalizeServerId(input.name); if (mcp.listServers().some((server) => server.id === id)) { await mcp.removeServer(id); } const callbackUrl = new URL(request.url); callbackUrl.pathname = callbackUrl.pathname.replace( /\/connect\/?$/, "/callback" ); callbackUrl.search = ""; await mcp.registerServer(id, { name: input.name, url: input.url, callbackUrl: callbackUrl.href, transport: { type: "auto" } }); const connection = await mcp.connectToServer(id); if (connection.state === "connected") { const discovery = await mcp.discoverIfConnected(id); return Response.json({ id, connection, discovery }); } return Response.json({ id, connection });

这个实现同时体现了管理器的三条设计约定:

  1. 回调 URL 由当前请求推导/connect路径被替换为/callback,即POST .../connect的同一对象路径,GET .../callback就是 OAuth 回调入口。README 强调:管理器不强制任何回调路由形状,你在registerServer()里传入什么精确 URL,就把那个请求路由到同一个 Durable Object 即可——管理器持久化该 URL,并且只会拦截来源与路径都匹配的回调请求。
  2. normalizeServerId生成稳定的存储主键:名称经过规范化后作为 server id,同时作为 SQLite 表cf_agents_mcp_servers的主键、AI SDK 工具名嵌入片段以及mcpConnections映射的键。核心实现 packages/agents/src/mcp/client/index.ts 的规则是:转小写 → 非[a-z0-9_-]字符序列替换为单个-→ 折叠连续-并去掉首尾-/_→ 空串或非字母开头时加id-前缀 → 截断到MCP_SERVER_ID_MAX_LENGTH(64)字符。例如normalizeServerId("GitHub MCP!")得到"github-mcp"normalizeServerId("42-things")得到"id-42-things"
  3. 连接与发现分离connectToServer()只负责建立连接(可能返回{ state: "authenticating", authUrl }等待授权,也可能直接connected);连接成功后调用discoverIfConnected()拉取服务器能力清单。registerServer()本身只做注册与持久化,README 明确建议新代码分别调用registerServer()connectToServer()

连接状态机与工具调用

/tools/call路由(demo-api.ts)在调用前检查连接状态:

const connection = mcp.mcpConnections[input.serverId]; if (!connection) { return Response.json({ error: "MCP server is not connected" }, { status: 404 }); } if (connection.connectionState !== "ready") { return Response.json( { error: `MCP server is not ready (${connection.connectionState})` }, { status: 409 } ); } const result = await mcp.callTool({ serverId: input.serverId, name: input.name, arguments: input.arguments });

前端StatusBadge(见 client.tsx)按状态着色:ready绿色、failed红色、authenticating黄色、其余蓝色,与后端的connectionState字段一一对应。工具调用失败时后端返回 502 并把底层错误消息透传给前端展示。

输入校验

connecttools/call都先经过严格的运行时校验(parseConnectInput/parseToolCallInput):名称与 URL 非空、URL 必须是http:https:协议、参数必须是 JSON 对象,否则返回 400。DELETE /servers/:id则直接从路径尾部提取并decodeURIComponent解码出 id 后调用removeServer(id),返回 204。

安全纵深:SSRF 防护

需要留意的是,管理器的registerServer()在源码层面还内置了SSRF 防护isBlockedUrl()会拒绝私网/内网地址的连接(见 packages/agents/src/mcp/client/index.ts),包括:

  • 明确封锁的主机名集合:0.0.0.0[::]metadata.google.internal
  • 私有/保留 IPv4 段:10.0.0.0/8172.16.0.0/12192.168.0.0/16169.254.0.0/16(link-local / 云元数据)、0.0.0.0/8
  • IPv6 私有段:fc00::/7(ULA)、fe80::/10(link-local,按/10边界正确匹配fe80febf)、以及::ffff:IPv4-mapped IPv6;
  • 环回地址(127.x.x.x::1)则被有意放行,以支持本地开发。

被拦截时会抛出Blocked URL: ... — MCP client connections to private/internal addresses are not allowed


四、RPC 边界:为什么getCatalog()要先lifecycle.start()

示例中McpClientObject除了onRequest()之外,还暴露了一个原生 RPC 方法getCatalog()

/** Native RPC bypasses fetch, so start the lifecycle explicitly. */ async getCatalog(): Promise<McpCatalog> { await this.lifecycle.start(); return getMcpCatalog(this.mcp); }

这是整个示例最值得深入理解的细节:

  • 经由fetch的 HTTP 请求,生命周期会自动为宿主启动并拦截回调,因此onRequest()内部无需手动启动;
  • Durable Object 原生 RPC 走的是RpcStub通道,完全绕过fetch,生命周期无法自动介入。因此 RPC 方法体内部必须显式await this.lifecycle.start(),确保管理器的 schema 已初始化、持久化连接已恢复,之后再安全地读取mcp.listServers()/mcp.listTools()

getMcpCatalog()(demo-api.ts)把管理器内部状态投影成 UI 可序列化的目录结构:

servers: mcp.listServers().map((server) => ({ id: server.id, name: server.name, url: server.server_url, state: mcp.mcpConnections[server.id]?.connectionState ?? "not-connected", authUrl: server.auth_url ?? undefined })), tools: mcp.listTools()

这里也展示了mcpConnections映射与listServers()的分工:前者是内存中的实时连接状态,后者是持久化的服务器元数据

关于 RPC 服务器的补充约定

官方文档还提到一个 RPC 相关的配置细节:如果目录中可能出现RPC 类型的服务器(而非纯 HTTP),则需要把 Durable Object 环境传给管理器,以便对象被唤醒(wake)后能解析持久化的 binding 名称:

readonly mcp = new MCPClientManager("my-object", "1.0.0", { env: this.env });

若存在 RPC 记录却没有传入env,启动时会输出一条警告日志(指明具体服务器),并且不会重建该连接。示例本身是 HTTP-only,因此无需传env


五、前端交互:实例隔离、OAuth 弹窗与轮询刷新

React 界面由 client.tsx 实现,包含几个值得复用的模式。

按浏览器隔离实例

const INSTANCE_KEY = "mcp-client-instance"; const instanceName = localStorage.getItem(INSTANCE_KEY) ?? crypto.randomUUID(); localStorage.setItem(INSTANCE_KEY, instanceName); const OBJECT_PATH = `/agents/mcp-client-object/${encodeURIComponent(instanceName)}`;

首次加载时生成一个crypto.randomUUID()作为随机对象名,存入localStorage。之后所有 API 请求都指向/agents/mcp-client-object/:instance。由于 Durable Object 以名字寻址,每个浏览器事实上拥有一个独立命名的对象实例,从而获得互相隔离的服务器目录和 OAuth 凭据存储。注意:路径段经过了encodeURIComponent,对象名可以安全地作为 URL 段使用。

OAuth 授权流程(两步弹窗)

const popup = window.open("about:blank", "_blank", OAUTH_WINDOW_FEATURES); // 640x760 const response = await fetch(`${OBJECT_PATH}/connect`, { method: "POST", ... }); const result = (await response.json()) as ConnectResult; if (result.connection.authUrl) { if (popup) { popup.opener = null; popup.location.assign(result.connection.authUrl); // 第一步:跳到授权页 } }
  • 第一步:提交连接后,若服务器返回authUrl(即authenticating状态),就把该 URL 加载进新开的about:blank弹窗。window.open时传入noopener以外的受限特性,并在跳转前清空popup.opener以增强安全性;
  • 回调完成:OAuth 服务商把用户重定向回对象自身的/callback路径。此时生命周期已把该 URL 注册给管理器,管理器拦截回调、完成 code 交换。后端的configureOAuthPopup()(demo-api.ts)为回调返回一个自动window.close()的 HTML 页面("MCP authorization complete — You can close this window."),并附带严格 CSP:default-src 'none'; script-src 'unsafe-inline'; style-src 'none',让弹窗在授权完成后自关闭;
  • 恢复授权入口:目录中authUrl非空的服务器会渲染为“Authorization required”卡片,提供“Authorize”按钮用第二个弹窗(noopener)直接打开授权页,便于对已授权但状态为authenticating的服务器继续完成流程。

轮询刷新目录

useEffect(() => { void refresh(); const timer = window.setInterval(() => void refresh(), 2_500); return () => window.clearInterval(timer); }, [refresh]);

前端每 2.5 秒拉取一次目录(GET OBJECT_PATH),因此授权状态从authenticating变为ready、新工具被discoverIfConnected()发现后,UI 会在数秒内自动更新,无需手动刷新。

工具卡片

ToolCard为每个发现的工具展示名称、描述、所属serverId徽章、可展开的inputSchema(JSON 格式化预览)以及“Arguments (JSON)”文本框。调用时前端把{ serverId, name, arguments }POST 到/tools/call,结果以 JSON 美化输出;output.isError === true时按错误态(红色)渲染。这种“schema 可展开 + JSON 参数直接填写”的交互,本质上就是 MCP 工具发现/调用协议在浏览器里的最小可视实现。


六、从示例到生产:与Agent方式的对照

官方文档 docs/agents/mcp-client.md 给出了与示例互补的另一种接入路径——直接继承Agent,使用更简洁的封装 API:

import { Agent } from "agents"; export class MyAgent extends Agent { async onRequest(request: Request) { const result = await this.addMcpServer( "github", "https://mcp.github.com/mcp" ); if (result.state === "authenticating") { return Response.redirect(result.authUrl); // 服务器需要 OAuth } const state = this.getMcpServers(); console.log(`Connected! ${state.tools.length} tools available`); return new Response("MCP server connected"); } }

关键依赖对齐:@modelcontextprotocol/client的精确版本需要与本 Agents 版本匹配(示例 package.json 固定为2.0.0)。

两种接入方式的取舍可以归纳为:

维度原生 DurableObject +Lifecycle(本文示例)继承Agent(官方 Quick Start)
继承DurableObject<Env>Agent<Env>
MCP 管理器手动new MCPClientManager(...)Lifecycle.install(this).use(...)框架内置this.mcp
常用 APIregisterServer/connectToServer/callTooladdMcpServer/removeMcpServer/getMcpServers
生命周期Lifecycle托管;RPC 方法需手动lifecycle.start()Agent托管
适用只想给现有 Durable Object 加 MCP 能力、不引入 Agent 语义需要 Agent 的对话、会话、调度等完整能力

无论哪种方式,底层都是同一个MCPClientManager能力对象——这也是该框架“能力可组合(composable capabilities)”设计的直接体现:Lifecycle作为能力运行容器,MCPClientManager作为能力本身,宿主只需声明式地use()即可获得 schema 初始化、连接恢复、OAuth 回调拦截三项自动服务。


七、小结

examples/next/mcp-client是一个“小而全”的参考实现,串联起了 Cloudflare Agents MCP 客户端能力的完整链路:

  • 装配DurableObject + Lifecycle.install().use(MCPClientManager),能力即插即用;
  • 持久化:SQLite(new_sqlite_classes)+registerServer()落库,驱逐后可恢复连接;
  • 路由routeAgentRequest()把请求送达同名对象,回调 URL 由/connect推导为/callback,管理器不限制路由形状;
  • 授权configureOAuthCallback+ 前端双弹窗完成 OAuth,回调由生命周期抢先拦截;
  • RPC 边界:绕过fetch的 RPC 方法需显式await this.lifecycle.start()
  • 安全normalizeServerId保证存储/工具名安全,SSRF 防护拒绝私网地址;
  • 交互crypto.randomUUID()实例隔离 + 2.5s 轮询 + 工具 schema 可视化,构成可直接借鉴的浏览器端 MCP 控制台。

如果希望进一步深入底层实现,建议顺藤摸瓜阅读 packages/agents/src/mcp/client/index.ts(管理器核心、normalizeServerId与 SSRF 防护)、packages/agents/src/lifecycle/capability.ts(能力运行容器的服务接口),以及 docs/agents/mcp-client.md(官方完整的 MCP 客户端文档,含Agent方式、OAuth、重试与恢复等进阶主题)。

【免费下载链接】agentsBuild and deploy AI Agents on Cloudflare项目地址: https://gitcode.com/GitHub_Trending/agents1/agents

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询