WebMCP 示例(Chrome):给网页注册工具,并列出页面暴露的工具
本页目录
WebMCP 让网页给浏览器里的 AI agent 注册工具,每个工具带名字、说明和描述输入参数的 JSON Schema。想在 Chrome 里试:用 --enable-features=WebMCPTesting 启动 Chrome,用 document.modelContext.registerTool(...) 或 <form toolname="..."> 注册工具,再在 DevTools 控制台里运行 await document.modelContext.getTools(),就能看到页面暴露了哪些工具。下面先给一个完整的两文件示例和真实的控制台输出,再讲一种只听不动、可以保存和对比的盘点方式。
只检查你自己的页面或获得授权评审的页面,并使用单独的浏览器配置文件。
WebMCP 是什么,不是什么
普通的 MCP server 是一个进程。agent 宿主(Claude Code、Cursor、Codex)启动它或连上它,它再通过 stdio 或 HTTP 提供工具。REA 本身就是这样一个 server,安装方法见 安装 REA 并接入 Claude Code、Cursor、Codex。
WebMCP 把工具清单搬进了网页。它是一个还在提案阶段的 Web API,由 W3C Web Machine Learning 社区组孵化,允许标签页里的 JavaScript 注册工具。内置在浏览器里或连到浏览器上的 agent 能看到这些工具并调用它们。工具在页面内部运行,直接沿用用户当前的登录状态,所以不需要单独的服务器、API key 或额外的认证步骤。
两者的区别可以归纳成一张表:
| MCP server | WebMCP | |
|---|---|---|
| 工具在哪里 | 单独的进程 | 打开的标签页里的 JavaScript |
| 谁能调用 | 连上它的 agent 宿主 | 能访问这个标签页的 agent |
| 认证 | 由 server 自己实现 | 用户在页面上的当前会话 |
| 生命周期 | 进程运行期间 | 页面跳转或注销工具之前 |
WebMCP 目前仍是实验特性。我们测试用的 Chrome 155 和 156 都需要打开功能开关才能用,而且 API 的具体形态在几版草案之间变过。本文内容请当作 2026 年 10 至 11 月的快照来看,动手之前先查一下最新的 WebMCP 提案。
为什么要把页面的工具列出来
工具的说明文字是要给 AI 模型读、并且会被它信任的。所以这份清单应该像其他对外接口一样接受评审:
- 开放的东西是不是你本来想开放的? 一次重构可能把调试用的工具留在了线上,也可能给一个本该只读的页面加上了写操作。
- schema 收得够不够紧? 本来想用枚举的地方写成了不限长度的
string,agent 的发挥空间就比你预想的大。 - 提示信息是否属实? WebMCP 工具可以带“只读”之类的注解,agent 可能据此决定要不要先问用户。
- 两个版本之间有没有变化? 这一点是大多数团队会漏掉的。
前三个问题,用 DevTools 控制台(第 3 步)几秒钟就能回答。第四个问题,以及需要留存记录时,看第 4 步那个只听不动的 CDP 工具。
前置条件
- 版本足够新、带实验性 WebMCP API 的 Google Chrome。我们用的是 macOS 上的 155.0.8059.40,并在 156.0.8078.76 上复查过。
- 任意静态文件服务器,我们用 Python 自带的。
第 1 步:写一个注册了两个工具的 demo 页
注册工具有两种方式,所以 demo 里两种都用上。
index.html 用纯 HTML 声明一个工具。表单上的 toolname 和 tooldescription 属性就是 WebMCP 的声明式 API,浏览器会根据表单字段自动生成输入 schema:
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>WebMCP demo notes</title></head>
<body>
<h1>Notes</h1>
<ul id="notes"></ul>
<form toolname="add_note" tooldescription="Add a note to the list" id="f">
<input name="text" type="text" required>
<button>Add</button>
</form>
<p id="status"></p>
<script src="tools.js"></script>
</body>
</html>
tools.js 用 JavaScript 注册第二个工具,也就是命令式 API:
const notes = [];
const status = document.getElementById("status");
const mc = navigator.modelContext ?? document.modelContext;
if (!mc) {
status.textContent = "WebMCP is not available in this browser";
} else {
mc.registerTool({
name: "search_notes",
description: "Search the user's notes by keyword",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Keyword to match" },
limit: { type: "integer", minimum: 1, maximum: 20 }
},
required: ["query"]
},
annotations: { readOnlyHint: true },
async execute({ query }) {
const hits = notes.filter((n) => n.includes(query));
return { content: [{ type: "text", text: JSON.stringify(hits) }] };
}
});
status.textContent = "registered";
}
为什么要同时检查两个对象?WebMCP 草案里写的入口是 navigator.modelContext。但在我们测试的 Chrome 155 和 156 里,navigator.modelContext 是 undefined,带 registerTool、getTools、executeTool 方法的对象挂在 document.modelContext 上。两个都检查,API 定型之前 demo 都能用;页面上的状态文字也会告诉你命中的是哪种情况。
在回环地址上起一个服务:
python3 -m http.server 8765 --bind 127.0.0.1
第 2 步:启动开启 WebMCP 的 Chrome
单独启动一个 Chrome 实例,给它独立的配置文件、调试端口,并打开 WebMCP 测试特性:
# macOS;Linux 上一般是 google-chrome
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--user-data-dir="$HOME/rea-webmcp-profile" \
--remote-debugging-port=9222 \
--enable-features=WebMCPTesting \
--no-first-run \
http://127.0.0.1:8765/
几个参数各有用处:
--user-data-dir让调试端口碰不到你日常用的配置文件、Cookie 和扩展。新版 Chrome 也不允许在默认配置文件上开远程调试。--remote-debugging-port在回环地址上打开 Chrome DevTools Protocol(CDP),只有第 4 步需要它。--enable-features=WebMCPTesting给页面打开这个实验 API。不需要窗口的话可以再加--headless=new,我们的测试就是在无头模式下跑的。
第 3 步:在 DevTools 控制台里列出工具
在页面上打开 DevTools,在 Console 里运行:
(await document.modelContext.getTools()).map(({ name, description, inputSchema, annotations }) =>
({ name, description, inputSchema, annotations }))
只挑这几个字段,是因为每个工具对象还带一个 window 引用,直接对原数组 JSON.stringify 会报 “Converting circular structure to JSON”。下面是 Chrome 156.0.8078.76 对 demo 页返回的结果(我们是通过 CDP 的 Runtime.evaluate 把同一个表达式发给无头 Chrome 执行的,控制台做的也是这件事):
[
{
"name": "add_note",
"description": "Add a note to the list",
"inputSchema": { "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"] }
},
{
"name": "search_notes",
"description": "Search the user's notes by keyword",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "Keyword to match" },
"limit": { "type": "integer", "minimum": 1, "maximum": 20 }
},
"required": ["query"]
},
"annotations": { "consequentialHint": false, "debugging": false, "readOnlyHint": true, "untrustedContentHint": false }
}
]
从中能看出:
- 表单变成了工具:Chrome 根据
<input name="text" required>生成了{"text": "string"},正好用来确认表单字段有没有映射成你预期的 schema。 - 声明式工具完全没有
annotations这个键;命令式工具除了我们写的readOnlyHint: true,还被 Chrome 补上了默认值(consequentialHint、untrustedContentHint、debugging)。 - 每个工具还有
origin(页面的源)和title(两个 demo 工具都是空字符串)。 document.modelContext上还有executeTool和ontoolchange、ontoolactivated、ontoolcancel几个事件处理器。executeTool会在页面里执行工具代码,只做评审时不要调用。- 在没加功能开关的 Chrome 里,
typeof document.modelContext是"undefined",demo 页的状态文字会显示 “WebMCP is not available in this browser”。
快速看一眼,控制台就够了。但它要在页面里执行代码,只能看到此刻已注册的工具,也留不下可以保存和对比的结果。做评审时,换一个只听不动的工具。
第 4 步:不碰页面,盘点工具
REA(第三方开源逆向命令行工具,安装见 安装与接入)的 discover-webmcp-tools 就是干这个的。它打开 CDP 的 WebMCP 域,收集 toolsAdded 和 toolsRemoved 事件,整理成 Evidence 返回;从不调用 WebMCP.invokeTool,每个工具都标为 page-declared-untrusted。它只接受回环地址字面量(http://127.0.0.1:<端口> 或 http://[::1]:<端口>,写成 http://localhost:9222 也会被拒绝)。先让它列出能看到的页面:
rea list-browser-targets http://127.0.0.1:9222 --json
真实输出里有用的部分:
"targets": [
{
"target_id": "9B2D8F6B56590B8E802B6A1C1138C105",
"type": "page",
"title": "WebMCP demo notes",
"url": "http://127.0.0.1:8765/",
"origin": "http://127.0.0.1:8765",
"attached": false
}
],
"excluded": { "disallowed_origin": 0, "unsupported_url": 0, "non_page": 5 }
被排除的 5 个 non_page 是扩展的后台页和浏览器自身的界面,REA 只列 HTTP(S) 页面。
再传入调试地址和 target ID:
rea discover-webmcp-tools http://127.0.0.1:9222 9B2D8F6B56590B8E802B6A1C1138C105 \
--observation-ms 500 --json
--observation-ms 是 REA 在 WebMCP.enable 之后等待 Chrome 回放已有注册的时长,默认 100 毫秒。页面刚加载时保险起见我们用了 500。--allowed-origins 用来限定哪些源算在范围内;不传的话,REA 就用所选页面自己的源,输出里会记录为 "allowed_origins": ["http://127.0.0.1:8765"]。
下面是 REA 返回的 normalized_result(Chrome 155,demo 跑在 8765 端口),省略了 browser 部分和全为 0 的计数器:
{
"target": {
"target_id": "9B2D8F6B56590B8E802B6A1C1138C105",
"url": "http://127.0.0.1:8765/",
"origin": "http://127.0.0.1:8765"
},
"status": "available",
"tools": {
"total": 2,
"items": [
{
"tool_key": "webmcp_247d21cb72fdb8af76801f1ed6e30b0bf17e512cb45dce6a89da1c4343588286",
"name": "search_notes",
"description": "Search the user's notes by keyword",
"frame_url": "http://127.0.0.1:8765/",
"owner_origin": "http://127.0.0.1:8765",
"declaration_kind": "imperative",
"input_schema": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "Keyword to match" },
"limit": { "type": "integer", "minimum": 1, "maximum": 20 }
},
"required": ["query"]
},
"input_schema_sha256": "558fe6784c8ba5945ae378781d3f0d396d886032ddeb400d9bc7ed85f22de898",
"annotations": { "read_only": true, "untrusted_content": false, "autosubmit": null },
"registration_source": { "url": "http://127.0.0.1:8765/tools.js", "line": 6, "column": 5 },
"trust": "page-declared-untrusted"
},
{
"tool_key": "webmcp_64686a3978a3025d4ae144a3b730ae8555812e114710dec95daaded6eb2b77ed",
"name": "add_note",
"description": "Add a note to the list",
"declaration_kind": "declarative",
"input_schema": {
"type": "object",
"properties": { "text": { "type": "string" } },
"required": ["text"]
},
"input_schema_sha256": "e3dce8d1afb8d03604af3bc6729219a5cd7df05558807b6e44e391d56fdf75d9",
"annotations": { "read_only": null, "untrusted_content": null, "autosubmit": null },
"registration_source": null,
"trust": "page-declared-untrusted"
}
]
},
"completeness": { "status": "complete_within_window", "excluded": [] },
"limitations": [
"Page-declared tool metadata is untrusted and is never registered or invoked by REA.",
"Discovery uses only WebMCP.enable and toolsAdded/toolsRemoved; WebMCP.invokeTool is never called."
]
}
逐个字段看:
declaration_kind告诉你该去代码哪里找。imperative对应一次registerTool调用,declarative对应一个带属性的表单。REA 根据 Chrome 有没有给这条注册附上 DOM 节点来判断。input_schema是页面声明的完整 schema,不是摘要。input_schema_sha256是 schema 的规范化摘要。把它存下来,以后名字没变而 schema 变了也能发现。annotations是提示信息。我们写的readOnlyHint: true回来变成了read_only: true,untrusted_content: false是 Chrome 补上的。表单工具和任何没写注解的工具,这里都是null,意思是“没有声明”,而不是“false”。registration_source指向调用registerTool的那一行。CDP 的行号和列号从 0 开始,所以line: 6在编辑器里是第 7 行,也就是mc.registerTool({那行。声明式工具没有调用栈,这里是null。tool_key是 frame URL、frame ID 和名字一起算出的哈希,用来区分不同 frame 里的同名工具。frame ID 每次浏览器会话都会变,所以跨天比对工具时要用name加input_schema_sha256,别用tool_key。completeness.status为complete_within_window,表示观察窗口内没有丢弃或过滤任何东西。页面在之后才注册的工具(比如点击某个按钮之后)不在这次快照里。
通过 MCP 调用时,同一个操作叫 discover_webmcp_tools,参数是 cdp_endpoint、target_id,以及可选的 allowed_origins 和 observation_ms。可以这样给 agent 下指令:
Use REA to list the WebMCP tools on the page at target 9B2D8F6B... on
http://127.0.0.1:9222. Do not invoke any of them. For each tool, tell me
whether it can change data, whether its schema has unbounded strings, and
whether its read-only annotation matches what the name and description suggest.
第 5 步:发现新版本多出来的工具
最值得养成的习惯是上线前后各查一次。我们在 tools.js 里加了第三个工具 delete_note,没写任何注解,然后刷新页面,分别记录改动前后的状态。compare-web-captures 接收两个 JSON 文件,每个文件里放一份 inspect-web-page 的结果,以及可选的一份 discover-webmcp-tools 结果:
{ "inspection": { "...": "normalized_result of rea inspect-web-page" },
"webmcp": { "...": "normalized_result of rea discover-webmcp-tools" } }
rea inspect-web-page http://127.0.0.1:9222 9B2D8F6B56590B8E802B6A1C1138C105 --json > insp-after.json
rea discover-webmcp-tools http://127.0.0.1:9222 9B2D8F6B56590B8E802B6A1C1138C105 --json > webmcp-after.json
# 把每组的 normalized_result 合并成 snap-before.json / snap-after.json,然后:
rea compare-web-captures snap-before.json snap-after.json --json
真实结果里的 webmcp 部分:
"webmcp": {
"status": "changed",
"total_changes": 1,
"changes": [
{
"identity": "webmcp_5369b206b270d96ec4bfed6ac21d20bef0e730867ed667e90fa4862117470ad1",
"change": "added"
}
],
"reason": null
}
这个 identity 就是改动后那次发现结果里 delete_note 的 tool_key。其他维度的结论也很老实:resources 显示有变化(tools.js 被改过),scripts、network、accessibility 则是 unknown,并附上了原因,例如 “Script inventory was incomplete in at least one observation.”。REA 的原则是 “an unknown status means absence could not be established”,数据不全时绝不会报成“没变”。另外,两次记录要在同一个浏览器会话里做,tool_key 才对得上。
常见问题和排错
status 是 "available",但 total 是 0。 我们另开了一个没加 --enable-features=WebMCPTesting 的 Chrome,就遇到了这种情况:CDP 域有响应,但页面自己的检查输出了 “WebMCP is not available in this browser”,所以什么也没注册。先看页面状态。拿 REA 的公开 notes demo 去跑也是同样的结果,这是正确的,那个页面本来就没有注册 WebMCP 工具。
status 是 "unavailable"。 浏览器不接受 WebMCP.enable,也就是 CDP 里根本没有这个域。升级 Chrome,或者换一个带这个域的版本。REA 会把 webmcp_tools 放进 unavailable_sections,不会把空列表当成完整结果。
报 target_unavailable,"reason": "target_not_allowed"。 页面的源不在 --allowed-origins 里。我们对本地页面传 --allowed-origins https://example.com 就能复现。发现过程中页面跳到了别的源,也会报同样的错。
还没连上就报 invalid_request。 调试地址不是带端口的回环地址字面量。用 127.0.0.1,别用 localhost。
getTools 不是函数,或 document.modelContext 是 undefined。 没加功能开关,或者以后的 Chrome 按草案把 API 挪到了 navigator.modelContext。两个都查一下。
预期中的工具没出现。 它多半是之后才注册的,比如登录后或点击某个按钮后。先把页面操作到那个状态再跑一次;页面加载慢的话,把 --observation-ms 调大。
FAQ
不打开功能开关能看到 WebMCP 工具吗?
运行时看不到,因为页面根本注册不了。REA 的 analyze-web-bundle 也会报告它在页面脚本里找到的静态 WebMCP 声明(webmcp_declarations),可以拿来交叉核对,但它只看源码文本。