WebMCP 实践
如今每个使用网站的 AI 代理都在猜测。
它截取屏幕截图,寻找看起来像按钮的东西,或者读取 DOM 并希望类名意味着它们看起来的含义。
然后它点击并检查页面是否以表明它有效的方式发生了变化。这是一个建立在糟糕基础上的令人印象深刻的派对技巧,因为网站从未告诉代理任何事情。代理推断了所有内容。
WebMCP 采取了不同的方法。
WebMCP 是 Chrome 提出的停止猜测的方案。你的页面声明它可以做什么,代理读取该声明,然后调用一个函数而不是瞄准一个矩形。
与其让代理弄清楚按钮的含义,页面可以声明它可以做什么。代理可以发现这些能力并使用结构化参数调用函数。
这听起来是一个小变化,但它改变了智能所在的位置。代理不再需要从像素和 HTML 重建应用程序的意图。你的应用程序可以直接陈述该意图。
我想知道这是否真的成立,所以我构建了一个工作看板,发布了源代码,并将一个真实的代理指向它。
现场演示: tusharkanjariya.github.io/web-mcp-demo
源代码: github.com/TusharKanjariya/web-mcp-demo
Chrome 文档: developer.chrome.com/docs/ai/webmcp
这篇文章讲述了发生的事情,包括代理断然拒绝我要求的那一刻,这被证明是整个实验中最有趣的结果。
1、各部分如何协同工作
在任何代码之前,命名三个参与者会有所帮助。这篇文章中的每一件事都是其中之一在执行其工作。
你的页面是提供者。 它注册工具。工具是一个名称、为模型编写的描述、参数的 JSON Schema 以及要运行的函数。
浏览器是注册表。 它保存该选项卡的工具列表,尽可能从 HTML 构建模式,并控制工具何时出现和消失。
代理是消费者。 它通过调用 getTools() 询问浏览器可用的内容,然后使用 executeTool() 运行一个。它从不接触你的 DOM,也无法发明你未发布的能力。
最后一句话是整个想法,也是本文其余部分真正关于的内容。
2、设置
我阅读 WebMCP 已经有一段时间了,但并没有真正理解它。Chrome 的文档清楚地解释了 API,但阅读解释器和理解事物是不同的活动。
所以我构建了它最小的可能版本:一个任务看板。
三个文件。index.html、app.js、style.css。没有构建步骤,也不需要安装任何东西。
你可以添加任务、标记完成、过滤视图。那种你写一个下午就再也不会去想的东西。
区别在于页面还通过一个浏览器提供的对象将自身作为一组可调用工具交给 AI 代理:
const mc = document.modelContext; // 如果 WebMCP 关闭则为 undefined
这就是整个入口点。如果它在那里,你的页面就可以注册工具。
如果不在,你的页面就是一个任务看板,仅此而已,这正是它应该降级的方式。
在截图之前需要了解一件事:我的演示页面在一个屏幕上显示两半。
左边的看板是提供者。右边的"假装代理"面板是消费者它调用 getTools() 和 executeTool() 的方式与真实代理完全一样,因此你可以无需连接任何东西即可观察两端。该面板出现在下面的每个截图中。
在任何工作之前你需要这个标志,因为 WebMCP 在 Chrome 平台状态中仍列为 Proposed,并且默认情况下未启用:
chrome://flags/#enable-webmcp-testing
Chrome 目前还提供 WebMCP 源试用,从 Chrome 149 开始。因此该标志对于本地实验很有用,而源试用是 Chrome 文档用于更广泛测试的路径。
3、工作时的样子
让我在解释它是如何构建之前向你展示完成的循环,因为一旦你看到它产生的效果,API 就更有意义了。
我将 Claude Code 连接到看板并输入一句话:
"我想让你在看板上添加以下任务:查找信息和研究、撰写博客大纲、用示例编写完整博客、发布到 Medium" → 四个任务已添加。
然后"好的,现在标记任务 3,4,5 完成" → 完成。
Claude 从未截取我的看板截图,也从未读取其 HTML。它询问浏览器存在哪些工具,找到 add-task,并使用结构化参数调用了四次。
这是从浏览器内部看到的同一时刻:
Claude 刚刚创建的四个任务现在是看板上的 #3–#6。
右边的 DevTools 面板(Application → WebMCP)列出了页面当前发布的所有六个工具。
注意列表中的 clear-completed 它一分钟前还不在,下一节将解释原因。
然后它标记完成的三个任务,逐个记录调用:
每个调用都记录了其参数:{"id":3}、{"id":4}、{"id":5}。 3 次总调用 · 0 次失败 · 0 次取消 · 0 次进行中。
这个面板是调试 WebMCP 的最快方式你可以确切地看到代理发送的内容。
这就是循环。终端中的一句话变成了浏览器选项卡内的四个结构化函数调用。
那么页面必须做什么才能赢得这个呢?
4、表单已经是工具。你只需要说出来。
有两种发布工具的方式,第一种根本不需要 JavaScript。你采用已经编写的表单并添加四个属性:
<form id="add-form"
toolname="add-task"
tooldescription="向用户的任务看板添加新任务。"
toolautosubmit>
<input name="title" required maxlength="80"
toolparamdescription="要添加的任务的简短描述。">
<select name="priority"
toolparamdescription="任务的紧急程度。">
<option value="low">低</option>
<option value="normal" selected>普通</option>
<option value="high">高</option>
</select>
<button type="submit">添加</button>
</form>
这就是 Claude 调用了四次的 add-task 工具。浏览器读取你的字段名称、required 属性和 <select> 选项,并自行构建 JSON Schema。
代理看到一个接受必需字符串和三值枚举的工具,没有人手动编写该模式。
这可能是最容易在现有应用程序中尝试的 WebMCP 实验。如果你已经有一个普通的 HTML 表单,你就不是从零开始。你正在添加该表单对代理意味着什么的描述。
这是我花了一点时间才理解的部分:相同的 submit 处理程序既服务于人类单击添加,也服务于代理调用工具。
事件告诉你哪个:
$('#add-form').addEventListener('submit', (e) => {
e.preventDefault();
const task = addTask(new FormData(e.target).get('title'), /* ... */);
if (!e.agentInvoked) {
e.target.reset(); // 人类 — 清空框
} else {
e.respondWith?.(Promise.resolve(
text(`已添加任务 #${task.id}: "${task.title}".`)
));
}
});
agentInvoked 告诉你是谁按下了按钮。
respondWith() 将结果交还给代理,而无需导航离开。一条代码路径,两个调用者。
我认为这是一个重要的设计细节。我不想要应用程序中每个操作的"AI 版本"。我希望相同的应用程序逻辑在有意义时同时服务于人类和代理。
老实说: 我认为声明式 API 是较不有趣的一半,我怀疑它是获得最多关注的一半,因为它演示得很漂亮,不需要思考。有趣的一半是你无法在表单中表达的内容。
5、来来去去的工具
第二种方式是 registerTool,它看起来大致像你为后端 MCP 服务器编写的内容:
await mc.registerTool({
name: 'list-tasks',
description: '列出看板上的任务。在操作之前使用此工具,以便你知道任务 ID。',
inputSchema: {
type: 'object',
properties: {
status: { type: 'string', enum: ['all', 'open', 'done'] },
},
},
annotations: { readOnlyHint: true },
async execute({ status = 'all' }) {
const rows = visible(tasks, status);
return text(rows.map((t) => `#${t.id} [${t.done ? 'x' : ' '}] ${t.title}`).join('\n'));
},
});
再次阅读该 description。
它是为模型编写的,而不是为开发者"在操作之前使用此工具,以便你知道任务 ID"是关于何时调用它的提示。
描述现在是你 API 表面的一部分,模糊的描述会产生猜测的代理。
readOnlyHint 告诉代理这个可以安全调用,无需先检查用户。list-tasks 和 estimate-task 有它。
complete-task 和 clear-completed 没有,因为它们会改变事物。
工具可能需要一段时间,代理可能会改变主意。
estimate-task 故意等待三秒钟,它获得 AbortSignal 作为第二个参数:
async execute({ id }, { signal }) {
const t = findTask(tasks, id);
if (!t) {
return text(`没有任务 #${id}.`);
}
await sleep(3000, signal); // 如果代理中止则抛出异常
return text(
`任务 #${t.id} 大约 ${
t.priority === 'high' ? '2 小时' : '30 分钟'
}.`
);
}
尊重该信号,取消就可以工作。忽略它,你的工具会在代理离开后继续运行。
但真正的原因是在脚本中注册工具而不是发布静态清单是:工具列表允许在页面打开时更改。
我的看板有一个 clear-completed 工具,可以删除每个完成的任务。
该工具只有在某些内容实际完成时才有意义这就是为什么它出现在之前的截图中,就在 Claude 标记三个任务完成之后。所以它只在那时存在:
let clearCtl = null;
async function syncClearTool() {
const has = tasks.some((t) => t.done);
if (has && !clearCtl) {
const ctl = new AbortController();
clearCtl = ctl;
await mc.registerTool(
{
name: 'clear-completed',
/* ... */
},
{
signal: ctl.signal
}
);
} else if (!has && clearCtl) {
const ctl = clearCtl;
clearCtl = null;
setTimeout(() => ctl.abort(), 0); // abort() 就是 unregister
}
}
没有 unregisterTool()。你注册时传递一个 AbortSignal,中止它就会删除该工具。一旦我看到这一点,API 设计就点击了取消正在运行的调用的相同信号也撤销了该能力。相同的机制,两项工作。
标记任务完成,clear-completed 就会出现在代理的工具列表中。取消标记,工具就会消失。代理通过 toolchange 事件发现,而不是轮询:
mc.addEventListener('toolchange', refreshTools);
所以你的工具列表不是你发布一次的清单。它是你的应用程序现在能做什么的实时描述。
6、这与后端 MCP 服务器有何不同
接下来我要求 Claude Code 将看板过滤为已完成的任务。这是我的屏幕上发生的事情:
代理使用 {"status":"done"} 调用了 filter-tasks。
完成按钮现在处于活动状态,看板仅显示已完成的任务。你将在下一节看到触发此操作的交换。
代理没有获取过滤后的数据副本。它改变了我正在查看的视图。
后端 MCP 服务器无法做到这一点。
它位于网络边界的另一侧,操作存储状态,不知道当前在任何人的屏幕上呈现的内容。
WebMCP 工具在选项卡内运行相同的 DOM、相同的会话、相同的 cookie。代理和人类正在查看一个屏幕,他们中的任何一个都可以更改它。
这是一个真正的能力,也是一个真正的风险,我将回到风险。
这就是为什么我不认为 WebMCP 简单地是"MCP 移入浏览器"。位置很重要。该工具连接到用户正在查看的实际页面状态。
7、我如何连接真正的代理
那个"假装代理"面板在开发时很有用,但它证明 nothing但它是页面调用自身。
为了正确测试,我需要浏览器外部的东西,该东西只能看到 getTools() 报告的内容。
所以 agent.mjs:一个小型 CLI 加上一些 Chrome DevTools Protocol 管道,零依赖。
Node 22 有一个全局 WebSocket,所以不需要安装任何东西。它只能看到 getTools() 报告的内容,并且只能通过 executeTool() 操作没有 DOM 抓取可供它使用,这是构造性的。
node agent.mjs list
node agent.mjs call add-task '{"title":"发布演示","priority":"high"}'
然后是产生上述截图的版本。
Claude Desktop、Claude Code 和 ChatGPT 不说 WebMCP 他们说 MCP。所以 mcp-bridge.mjs 是适配器,它很小,因为两个协议几乎完全对齐:
tools/list→getTools()tools/call→executeTool()
用 .mcp.json 将其连接到 Claude Code:
{
"mcpServers": {
"webmcp-board": {
"command": "node",
"args": ["C:\\projects\\web-mcp-demo\\mcp-bridge.mjs"],
"env": { "PAGE_URL": "https://tusharkanjariya.github.io/web-mcp-demo/" }
}
}
}
所以完整的链条是:Claude Code 通过 MCP 与桥对话,桥通过 CDP 与 Chrome 对话,Chrome 调用我的页面注册的工具。四次跳转,页面端不知道它在与哪个客户端对话。
Claude Code
↓
MCP
↓
mcp-bridge.mjs
↓
Chrome DevTools Protocol
↓
WebMCP
↓
我的任务看板
一个值得暂停的细节:如果调试端口没有响应,桥会启动自己的 Chrome,并且如果该选项卡尚不存在,则打开该页面。MCP 客户端可以在没有其他任何东西运行的情况下冷启动整个过程。
8、拒绝
然后我要求它删除单个任务。
上半部分是你刚刚看到的过滤请求结果。
下半部分是有趣的部分:"看板上没有 delete-task 工具唯一的删除是 clear-completed,它也会清除 #2、#3、#4 和 #5。不这样做。"
它拒绝不是因为保护措施,或系统提示,或我附加的某些安全层。它拒绝是因为该功能不存在,而且它实际上可以分辨。
我想小心我对此投入多少权重,因为这是一个玩具应用程序上的一次交互,我不想将任务看板变成关于计算未来的论文。但它确实改变了我对这个问题的看法。
通过读取像素和单击坐标驱动页面的代理会找到看起来像删除的内容并尝试它。
这就是屏幕抓取代理所做的它们即兴发挥,因为即兴发挥是它们唯一拥有的。这个没有即兴发挥。
它枚举了它拥有的六个工具,发现没有一个意味着"删除一个任务",注意到唯一相邻选项中的附带损害,并停下来询问。
工具表面就是边界。没有人编写规则说"不要删除东西"页面只是从未将删除作为可能发生的事情提供,这被证明已经足够。
这并不意味着 WebMCP 会神奇地使代理安全。
9、Chrome 与 WebMCP 规范不一致的地方
我发现的下一部分是通过运行它,而不是通过阅读它,它花费了我一个下午。
文档和浏览器不一致的三个地方:
executeTool() 参数。规范说它接受一个对象。Chrome 151 需要一个 JSON 字符串传递一个对象,你会得到 Failed to parse input arguments。
RegisteredTool.inputSchema。规范将其类型定义为对象。Chrome 给你一个 JSON 字符串,所以在读取 properties 之前解析它。
executeTool() 解析为的内容。规范说 DOMString,这是真的但它是序列化的 {content:[…]} 信封,所以你需要解包两次才能到达文本。
一旦你知道,这些都不难。真正痛苦的两个是生命周期错误,只有在真实代理驱动时才会遇到:
在代理调用的提交期间调用 form.reset() 会取消工具调用。错误消息是 Tool execution cancelled by a form reset。
我有一个完全普通的表单处理程序,在提交后清除输入这是我编写的大多数表单处理程序所做的每个代理驱动的 add-task 都死在它上面。任务被添加了。响应从未返回。这就是为什么上面的处理程序仅为人类提交重置。
在 Chrome 153 之前,注销工具会取消该工具的正在进行的调用。 这很好,直到你有一个通过成功运行来注销自身的工具。
clear-completed 删除每个已完成的任务;一旦它们消失,就没有完成的内容,因此该工具会删除自身,在 151 上,该中止在响应返回之前就杀死了自己的响应。
因此 setTimeout(() => ctl.abort(), 0)。将中止延迟一个刻度,调用先解析。
我仍然觉得这有点不舒服。它有效,我可以解释它为什么有效,但"将拆卸延迟一个刻度"是那种让我想在几个 Chrome 版本后回来检查的修复。
10、关于发布它的简短旁注
演示完全是静态的,因此 GitHub Pages 按原样托管它,HTTPS 提供 WebMCP 所需的安全上下文。
这为你提供了一个适用于所有人的工作任务看板。它没有为任何人提供工作 WebMCP没有标志的访问者仍然看到 document.modelContext 为 undefined。
为此,你需要在 <head> 中有一个源试用令牌,注册为第一方(Chrome 拒绝元标记中的第三方令牌)。在没有标志的干净配置文件上验证:modelContext: "object",所有六个工具。该试用运行到 Chrome 156。
就是这样。这就是旁注。
11、我没有很好答案的部分
WebMCP 工具在登录的选项卡内执行。这就是重点,也是整个问题。
规范有一个 untrustedContentHint,它正是名称所说的提示。提示注入威胁模型在规范中仍然是一个开放问题,并且此 API 中没有任何内容是授权边界。
如果一个页面可以被说服渲染攻击者控制的文本,并且代理读取该文本,你就会遇到任何数量的 readOnlyHint 都无法解决的问题。
我的桥也可以通过隧道在 HTTP 上运行,以便 ChatGPT 的连接器可以访问它。
我构建了它,它有效,我会告诉你在使用它之前要仔细考虑:该隧道发布了一个无身份验证端点,该端点在登录的浏览器选项卡内执行工具,任何猜测 URL 的人都可以访问。
适合二十分钟的实验。不适合整夜运行。
12、我实际上会告诉你要做的事情
如果你维护一个 Web 应用程序,并且想知道是否要关心 WebMCP:该 API 足够小,你可以在一个下午内找到答案。
采用你已有的一个表单。添加 toolname 和 tooldescription。打开 DevTools WebMCP 面板并观察它出现。
那是十分钟,它会告诉你比再花一个小时阅读包括阅读本文更多的内容。
我想让你带走的不是语法。而是诚实地描述应用程序的功能现在是你发布的功能,而不是你之后编写的文档。
我的看板无法删除单个任务,所以代理没有删除。你的 getTools() 返回的内容就是代理相信你的应用程序能做什么越来越多地,这种信念就是接口。
六个工具,三个文件,没有依赖。去看看你自己的页面会说什么。
13、获取代码
这篇文章中的所有内容都是公开的看板、CLI 代理和 MCP 桥。
源代码: github.com/TusharKanjariya/web-mcp-demo
现场演示: tusharkanjariya.github.io/web-mcp-demo
克隆它,启用 chrome://flags/#enable-webmcp-testing,然后运行 node agent.mjs list。如果你得到六个工具返回,你就正在与网页进行代理对话。
常见问题
- 什么是 WebMCP?WebMCP 是一个提议的浏览器 API,允许网页通过
document.modelContext将其自身功能作为可调用工具暴露给 AI 代理。代理直接调用页面的工具,而不是抓取 DOM 或单击坐标。Chrome 的文档位于 developer.chrome.com/docs/ai/webmcp。 - WebMCP 与普通 MCP 服务器有何不同?后端 MCP 服务器运行在网络边界的另一侧,操作存储状态。WebMCP 工具在选项卡内运行,因此它们可以更改用户当前正在查看的内容,并且它们继承页面的现有会话和权限。当我的代理调用
filter-tasks时,实际可见的看板发生了变化。 - 页面如何注册 WebMCP 工具?两种方式。向现有表单添加
toolname和tooldescription属性,浏览器会从字段构建模式,或者在 JavaScript 中使用名称、描述、JSON Schema 和execute函数调用document.modelContext.registerTool()。对简单操作使用表单,对表单无法表达的任何内容使用registerTool。 - 使用 WebMCP 需要启用标志吗?目前,是的。启用
chrome://flags/#enable-webmcp-testing并重新启动 Chrome,或使用--enable-features=WebMCP启动。对于尚未设置标志的访问者,你可以注册第一方源试用令牌并将其放在页面的<head>中。 - Claude Desktop 或 ChatGPT 可以使用 WebMCP 工具吗?不能直接它们说 MCP,而不是 WebMCP。一个将
tools/list映射到getTools()并将tools/call映射到executeTool()的小型适配器桥接了两者。我的大约 130 行,没有依赖,并且通过 stdio 为本地客户端工作。 - WebMCP 安全吗?将其视为未解决。
untrustedContentHint是一个提示,readOnlyHint是一个提示;两者都不是授权边界,并且提示注入威胁模型在规范中仍然是开放的。设计你的工具表面,就好像代理可能被说服调用你暴露的任何内容一样,因为它可能会。
原文链接:WebMCP: When Websites Become AI Tools
汇智网翻译整理,转载请标明出处