Puppeteer 与 WebMCP 实战指南:让网页工具被浏览器与 LLM Agent 发现与调用
WebMCP(Web Machine Context Protocol)是一项实验性 API,允许网页注册可供浏览器或外部 Agent(如 LLM)发现和调用的工具;本指南讲解如何在 Puppeteer 中启用 WebMCP、发现页面已注册的工具、执行与取消工具调用、监听调用全过程,以及如何在页面内以命令式或声明式方式注册工具。读完本文,你将能够基于 Chrome 151+ 构建“网页暴露能力 → Puppeteer/Agent 自动发现 → 程序化调用并回收结果”的完整链路。
⚠️ 实验性说明:WebMCP 是实验性 API,随时可能变更。目前仅在 Chrome 151+ 中支持,并且需要显式开启对应 Flag(
--enable-features=WebMCP)。本指南依据当前仓库 docs/guides/webmcp.md 及其底层实现编写。
前提条件
要在 Puppeteer 中使用 WebMCP,需要同时满足两个条件:
- Chrome 151+:浏览器必须支持 WebMCP CDP 域(
WebMCP.*),所有工具发现与调用最终都经由该 CDP 域完成。 - 开启实验特性 Flag:启动浏览器时必须携带:
--enable-features=WebMCP
在 Puppeteer 中启用 WebMCP
WebMCP 支持通过 page.webmcp 属性暴露给用户,它属于 WebMCP 类,继承自 EventEmitter(事件映射见 puppeteer.webmcp.md):
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
args: ['--enable-features=WebMCP'],
});
const page = await browser.newPage();
// page.webmcp 现在可用
console.log(page.webmcp);
当你导航到某个页面时,若浏览器支持 WebMCP,page.webmcp 会被自动初始化。从源码看,这一过程发生在页面初始化阶段:packages/puppeteer-core/src/cdp/Page.ts 中,#initialize() 会与 FrameManager 初始化等任务并行调用 this.#webmcp.initialize(),后者向 CDP 会话发送 WebMCP.enable 命令(见 packages/puppeteer-core/src/cdp/WebMCP.ts)。值得注意的是,WebMCP.enable 失败时错误会被捕获并仅记录日志而不会中断页面加载,因此在不支持的浏览器上访问该属性不会抛出致命异常;page.webmcp 的抽象 getter 声明在 packages/puppeteer-core/src/api/Page.ts。
提示:
page.webmcp在页面初次导航前可能尚未完成工具枚举,建议先await page.goto(...)等待工具事件(见下文)或就绪后再读取工具列表。
WebMCP 类概览:事件与底层数据流
WebMCP 类把 CDP 的 4 类协议事件翻译成 4 个可供 Puppeteer 订阅的领域事件。下表汇总了事件签名与触发时机(依据 WebMCP.ts 及 API 文档 puppeteer.webmcptoolsaddedevent.md):
| 事件 | 载荷类型 | 触发时机 |
|---|---|---|
toolsadded |
WebMCPToolsAddedEvent(含 tools: WebMCPTool[]) |
页面注册新工具(命令式或声明式) |
toolsremoved |
WebMCPToolsRemovedEvent(含 tools: WebMCPTool[]) |
页面注销工具,或工具所在 frame 的执行上下文被销毁 |
toolinvoked |
WebMCPToolCall(含 id、tool、input) |
某次工具调用开始(调用方是页面或浏览器) |
toolresponded |
WebMCPToolCallResult |
工具调用完成、出错或取消 |
在实现内部,WebMCP 使用两层 Map 按 frame 维度维护每个工具:#tools(frameId → (toolName → WebMCPTool))和 #pendingCalls(invocationId → WebMCPToolCall)。CDP 的 WebMCP.toolsAdded / toolsRemoved / toolInvoked / toolResponded 事件在构造时被绑定到对应处理器(见 WebMCP.ts)。当 frame 的上下文销毁时,#onContextDisposed 会清空挂起调用,并把该 frame 的所有工具作为一次 toolsremoved 广播出去(WebMCP.ts),这保证了不会残留“僵尸工具”状态。
WebMCPToolCall 中的 input 是 JSON 字符串解析后的对象:若解析失败,input 会退化为 {} 并记录错误日志(WebMCP.ts),实际开发中最好确保输入总是合法 JSON。
发现页面上的工具
用 page.webmcp.tools() 获取当前页面所有已注册工具;它把内部 Map 展平为 WebMCPTool[] 数组(WebMCP.ts)。每个 WebMCPTool 暴露以下属性(参见 puppeteer.webmcptool.md):
name: string—— 工具名称;description: string—— 工具用途描述(供 Agent 决策);inputSchema?: object—— 输入参数的 JSON Schema,用于生成符合规范的调用参数;annotations?: object—— 可选注解(测试中可见readOnly、untrustedContent、autosubmit等能力位);frame: Frame—— 工具定义所在 frame;location?: ConsoleMessageLocation—— 定义该工具的源码位置(命令式注册且能取到堆栈时才有值);formElement(getter)—— 若由 HTML 表单注册,返回对应的ElementHandle<HTMLFormElement>。
// 获取当前已注册的工具
const tools = page.webmcp.tools();
for (const tool of tools) {
console.log(`Tool found: ${tool.name} - ${tool.description}`);
}
// 监听新增工具
page.webmcp.on('toolsadded', event => {
for (const tool of event.tools) {
console.log(`New tool added: ${tool.name}`);
}
});
// 监听工具被移除
page.webmcp.on('toolsremoved', event => {
for (const tool of event.tools) {
console.log(`Tool removed: ${tool.name}`);
}
});
tools() 是同步快照方法,因此典型的“先注册、后发现”模式需要借助 toolsadded 事件做同步。仓库测试 test/src/cdp/webmcp.test.ts 展示了标准做法:先在页面中分别注册命令式工具与声明式工具,等待 toolsadded 计数达到预期后再调用 page.webmcp.tools() 断言长度为 2,并逐项校验 name、description、inputSchema、annotations、frame 与 formElement。
执行工具
通过 WebMCPTool 上的 execute 方法调用已发现的工具,该方法返回一个以调用结果为值的 Promise(WebMCPTool.execute):
const tools = page.webmcp.tools();
const tool = tools.find(t => t.name === 'calculate_sum');
if (tool) {
const result = await tool.execute({a: 5, b: 10});
if (result.status === 'Completed') {
console.log('Result:', result.output);
} else {
console.error('Error:', result.errorText);
}
}
execute(input, options) 内部执行两步:先通过 CDP WebMCP.invokeTool 拿到 invocationId,再挂起等待与 invocationId 匹配的 toolresponded 事件到来。这解释了两个细节:
execute返回的WebMCPToolCallResult.id与内部WebMCPToolCall.id是同一个调用标识,事件监听者可用它把调用与响应一一对应;- 由于实现建立在事件匹配上,同一时刻多个工具并发执行也互不干扰。
WebMCPToolCallResult 的完整字段如下(参见 puppeteer.webmcptoolcallresult.md):
| 字段 | 类型 | 说明 |
|---|---|---|
id |
string |
调用标识符 |
call? |
WebMCPToolCall |
对应的调用对象(若可用) |
status |
WebMCP.InvocationStatus |
调用状态(如 Completed / Canceled / Error) |
output? |
any |
工具输出;仅当 status 为 Completed 时存在 |
errorText? |
string |
错误文本 |
exception? |
Runtime.RemoteObject |
工具抛出 JS 异常时的异常对象 |
关于失败场景,仓库测试给出了两个可验证的事实:当工具的 execute 函数直接 throw 时,响应状态为 Error,output 为 undefined,exception 中携带包含错误消息的 description;而面向“工具不存在/参数错误”等错误则通过 errorText 承载(webmcp.test.ts 中分别断言了异常与 errorText 两种分支)。因此可靠的业务代码应同时检查 status、errorText 与 exception 三种信号,而不只判断 status === 'Completed'。
取消正在执行的工具
execute 接受第二个参数 WebMCPToolExecuteOptions,目前仅支持 signal?: AbortSignal。传入 AbortSignal 后,一旦信号触发,Puppeteer 会向浏览器发送 WebMCP.cancelInvocation 请求取消对应调用(WebMCP.ts)。如果调用发起时信号已处于 aborted 状态,也会立即触发取消:
const controller = new AbortController();
// 2 秒后取消执行
setTimeout(() => {
controller.abort();
}, 2000);
const result = await tool.execute(
{query: 'large data processing'},
{signal: controller.signal},
);
if (result.status === 'Canceled') {
console.log('Tool execution was canceled.');
}
取消成功时,响应状态为 Canceled。取消逻辑的实际挂接点位于 execute 内部:它在注册 toolresponded 监听器的同时为 signal 挂上 abort 处理器,并在得到与本次 invocationId 匹配的响应后自动解除监听与取消回调,避免内存泄漏。
观察工具被调用并读取响应
除主动 execute 外,你还可以监听页面自身或浏览器发起的工具调用,这在审计、埋点与调试 Agent 行为时非常有用:
page.webmcp.on('toolinvoked', call => {
console.log(`Tool ${call.tool.name} was invoked with input:`, call.input);
});
page.webmcp.on('toolresponded', response => {
console.log(
`Tool ${response.call?.tool.name} responded with status: ${response.status}`,
);
if (response.status === 'Completed') {
console.log('Output:', response.output);
} else if (response.status === 'Canceled') {
console.log('Invocation was canceled');
} else {
console.log('Error:', response.errorText);
}
});
注意两点语义:
toolinvoked同时在两级上触发:WebMCP实例(page.webmcp.on(...))以及被调用的WebMCPTool实例(tool.once('toolinvoked', ...)),后者便于只关注某个具体工具的调用;- 当调用由页面内部发起(例如
modelContext.executeTool(...)),只要工具已注册,Puppeteer 侧同样能收到toolinvoked与toolresponded。仓库测试正是利用这一能力验证“页面发起调用 → Puppeteer 收到事件 →response.output与注册时execute的返回值一致”的闭环(webmcp.test.ts)。
在页面中注册工具
工具既可命令式注册(JavaScript),也可声明式注册(带特定属性的 HTML 表单)。Puppeteer 页面本身就是一个 WebMCP 页面,因此这两种方式都可直接在页面上下文中演示。
命令式注册
通过 page.evaluate 在页面内调用 Web 平台侧的 document.modelContext?.registerTool(...),注册时需要提供 name、description、inputSchema 与 execute:
await page.evaluate(async () => {
await document.modelContext?.registerTool({
name: 'calculate_sum',
description: 'Calculates the sum of two numbers',
inputSchema: {
type: 'object',
properties: {
a: {type: 'number'},
b: {type: 'number'},
},
required: ['a', 'b'],
},
execute: ({a, b}) => {
return a + b;
},
});
});
命令式注册还支持可选的 annotations 与取消信号({signal: controller.signal})。仓库测试中注册了 {readOnlyHint: true, untrustedContentHint: true},随后在 Puppeteer 侧断言工具 annotations.readOnly === true 与 annotations.untrustedContent === true,证明注解信息会原样从页面透传到 WebMCPTool.annotations。
声明式注册
WebMCP 也能发现带有特定属性的 HTML 表单并把它转成工具。最基本的属性是 toolname 与 tooldescription,测试中还出现了 toolautosubmit(允许表单自动提交执行);表单内的 <input name="..."> 即工具的输入字段:
await page.setContent(`
<form
toolname="search_products"
tooldescription="Search for products in the catalog"
>
<input name="query" type="text" />
<button type="submit">Search</button>
</form>
`);
声明式工具在 Puppeteer 侧同样表现为 WebMCPTool,但有两个区别于命令式工具的可验证特征:
inputSchema会由浏览器根据表单输入字段自动推导(测试中断言空表单对应{type: 'object', properties: {}, required: []});tool.location为undefined,而tool.formElement返回对应的表单元素句柄(因为底层是通过backendNodeId在主世界隔离域中adoptBackendNode得到的 ElementHandle,见 WebMCP.ts)。
工具通过表单注册后,可以这样取得对应的 ElementHandle 做进一步 DOM 操作:
const tools = page.webmcp.tools();
const searchTool = tools.find(t => t.name === 'search_products');
const formHandle = await searchTool.formElement;
formElement 返回类型为 Promise<ElementHandle<HTMLFormElement> | undefined>:仅在工具由表单声明且表单尚未被移除时才返回有效句柄,命令式注册的工具取值为 undefined。若要移除工具,命令式场景调用 modelContext 上对应的注销 API,声明式场景则把表单从 DOM 中移除——两者都会触发 toolsremoved 事件,测试用例对此均有覆盖。
一个端到端综合示例
把上文各环节串起来:启动带 Flag 的浏览器 → 打开页面 → 注册/等待工具 → 发现 → 执行 → 观察响应:
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
args: ['--enable-features=WebMCP'],
});
const page = await browser.newPage();
const toolsAdded = new Promise(resolve => {
page.webmcp.once('toolsadded', resolve);
});
await page.goto('https://example.com'); // 页面内已注册若干工具
await toolsAdded;
const tool = page.webmcp.tools().find(t => t.name === 'calculate_sum');
if (!tool) {
throw new Error('tool not found');
}
page.webmcp.on('toolresponded', response => {
console.log(response.status, response.output ?? response.errorText);
});
const result = await tool.execute({a: 5, b: 10});
console.log(result);
await browser.close();
常见问题与注意事项
- 为什么
page.webmcp.tools()返回空数组? 可能原因:Chrome 版本低于 151;启动时未传--enable-features=WebMCP;页面尚未加载完成或页面本身没有注册任何工具。可以注册toolsadded事件做异步等待,避免在toolsadded派发前读取快照。 execute的返回与监听事件重复? 这不是重复,而是两种互补的消费方式:execute的 Promise 只解析与你发起的调用匹配的那一次toolresponded;全局toolresponded监听则覆盖页面或浏览器发起的全部调用,适合统一记录。- 取消是尽力而为的:
WebMCP.cancelInvocation出错时错误仅被记录而不抛出,若工具本身无法中断,状态可能不会变为Canceled,业务上需保留超时兜底。 - 受限与实验性:整套 API 属于实验特性,接口可能随 Chrome/WebMCP 标准演进而变化。集成到生产环境前,务必基于锁定的 Chrome 版本回归验证(仓库测试通过独立的带 Flag 浏览器实例执行,见 webmcp.test.ts)。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00