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并安装该管理器,而不必扩展Agent。Agent内部其实也是直接安装同一个能力对象,因此现有的this.mcp、addMcpServer()、removeMcpServer()、getMcpServers()等 API 在该框架中依然可用。
生命周期(Lifecycle)为管理器做了什么
Lifecycle.install(this).use(this.mcp)把管理器注册进宿主对象的生命周期,由生命周期自动完成三件关键工作:
- 初始化 Schema:在
onStart()阶段初始化管理器的数据库表结构与持久化状态,之后宿主才开始处理工作; - 恢复被驱逐后的 HTTP 连接:Durable Object 被逐出内存后,对象重新冷启动时生命周期会恢复此前持久化的 MCP 连接;
- 回调 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 typecheck(tsc --noEmit)与pnpm run types(wrangler 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.bindings把McpClientObject类绑定到同名引用,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 });这个实现同时体现了管理器的三条设计约定:
- 回调 URL 由当前请求推导:
/connect路径被替换为/callback,即POST .../connect的同一对象路径,GET .../callback就是 OAuth 回调入口。README 强调:管理器不强制任何回调路由形状,你在registerServer()里传入什么精确 URL,就把那个请求路由到同一个 Durable Object 即可——管理器持久化该 URL,并且只会拦截来源与路径都匹配的回调请求。 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"。- 连接与发现分离:
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 并把底层错误消息透传给前端展示。
输入校验
connect与tools/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/8、172.16.0.0/12、192.168.0.0/16、169.254.0.0/16(link-local / 云元数据)、0.0.0.0/8; - IPv6 私有段:
fc00::/7(ULA)、fe80::/10(link-local,按/10边界正确匹配fe80–febf)、以及::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 |
| 常用 API | registerServer/connectToServer/callTool | addMcpServer/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),仅供参考