这个博客的 Cmd+K 搜索框背后是一份 /index.json,178 条记录,每条带标题、日期、URL、正文前 500 字、tags 和 keywords。人用得上它,浏览器里的 agent 用不上,因为 agent 不知道这个文件存在。

WebMCP 让页面把这类东西直接声明成 agent 可以调的工具。我这次挂上去 5 个,其中 4 个的数据源就是那份现成的索引,第 5 个读的是当前页的正文。在 meirong.dev 上跑通了,中间有两类问题:一类让 5 个工具一个也注册不上,原因有两个,而且都不报错;另一类是工具注册上了,答出来的数却和读者在站上看到的不一样。

这是个还在 origin trial 里的 API,随时可能变。我写这篇时(2026-09-10),注册页上写的可用范围是 Chrome 149 到 156,结束日期「no later than Nov 17, 2026」。下面所有实测都在 Chrome 152 上做的。

agent 现在只能截图、扒 DOM、模拟点击#

先说这套东西冲着什么来的,不然后面的代码看着只是又一个 API。

一个 agent 今天要在网页上做事,手段和人差不多:截图,抓 DOM 和 accessibility tree 的快照,看明白页面长什么样,然后模拟点击和键盘输入。WebMCP 的 explainer 把这类做法归为「已有的网页操作手段」,并且说得挺直白:就算 agent 做成了,简单操作往往也要好几步,慢而且不可靠。原因不难理解,DOM 是给人看的一份渲染结果,不是给程序读的数据模型,页面改版一次,靠视觉位置认出来的「那个按钮」就换了地方。

那为什么不让站点直接提供一个 API 或者 MCP server?explainer 列了三条代价,都是针对交互型 web 应用说的:

  • agent 直接和后端说话,绕开了站点自己的 UI,页面上显示的状态可能和后端已经不一致;
  • 用户的登录态、当前上下文和凭证,都得在那台服务器上再复制一份;
  • 本来是客户端的能力,为了给 agent 用,得专门写一个后端出来。

第三条正是我这个博客的处境:那份搜索索引本来就在浏览器里,为它单独起一个服务不合算。

页面自己声明工具,浏览器居中转发#

WebMCP 的做法是让页面在运行时自己声明「我支持哪些操作」,浏览器居中做中介。一次调用的完整来回是这样:

sequenceDiagram participant P as 页面 participant B as 浏览器 participant A as agent P->>B: 1. registerTool:名字、说明、JSON Schema、一个回调 A->>B: 2. getTools:这一页有哪些工具 B-->>A: 只给声明,不给实现 A->>B: 3. executeTool:选中的工具加参数 B->>P: 4. 在页面里调那个回调 P->>P: 5. 走页面自己的代码,并更新 UI P-->>B: 6. content block 形式的结果 B-->>A: 结果

要紧的是第 1 步和第 4 步的分工。注册时交出去的只有声明,实现那段代码始终留在页面里;agent 拿不到函数本身,想执行只能通过浏览器转一道。

这个分工带来两件事。一是 execute 里可以直接调页面上已有的函数,也就是 UI 上那个按钮点下去会走的同一条路,explainer 把「任何用户能在页面 UI 上完成的事都能复用现成的客户端代码做成工具」列为设计目标之一。所以第 5 步里页面的状态和界面会跟着一起更新,不会像绕开 UI 那样对不上。二是这段代码跑在页面里,凡是页面能拿到的东西它也能拿到,这一点放到后面和 MCP server 对比那节再展开。

规范里给的心智模型很好记:用了 WebMCP 的页面相当于一个「页内的 MCP server」,只不过它暴露的是客户端逻辑和 DOM 操作,而不是服务端 API。这也解释了为什么 execute 可以按 MCP 的 content block 来给返回值,数据形态照抄了 MCP,传输方式和 MCP 的 transport 没有关系。规范和 Chrome 文档的示例里也有直接返回一个 JSON 甚至一个字符串的,我这里统一按 content block 给。

这两条路不排斥。文档里写明如果某件事页面没给对应的工具,agent 可以退回去用通用的浏览器自动化。挂工具是给它一条更好走的路,不是把别的路堵上。

页面上的工具长什么样#

入口是 document.modelContext,浏览器自己提供的一个对象,跟 document.cookie 一样挂在 document 上,没有 npm 包也没有 CDN 脚本要引。背后的提案在 W3C 的 Web Machine Learning Community Group 里孵化(仓库就是 webmachinelearning/webmcp),目前是草案,不是定稿的标准。

前面说的「trial」指的是 Chrome 的 origin trial:一个 API 还没定稿时,Chrome 允许个别站点先拿真实流量试,条件是站点自己去注册,并在每个页面上带一张按 origin 签发的 token(放 meta 标签或者响应头都行)。没有 token 的页面拿不到这个 API,而且是彻底拿不到,document 上连 modelContext 这个属性都没有,不会出现「有但调用失败」。所以第一件事是探一下,没有就整个跳过:

const ctx = document.modelContext;
if (!ctx || typeof ctx.registerTool !== 'function') return;

一个工具就是四件东西:名字、给模型看的说明、JSON Schema 描述的入参、以及真正干活的 execute。我这里最小的那个是按 tag 列条目:

await ctx.registerTool({
  name: 'list_entries_by_tag',
  description: 'List entries carrying a given tag, newest first.',
  inputSchema: {
    type: 'object',
    properties: {
      tag: { type: 'string', description: 'A tag from list_tags, for example "kubernetes" or "system-design".' },
      limit: { type: 'integer', minimum: 1, maximum: 50 },
    },
    required: ['tag'],
  },
  annotations: { readOnlyHint: true },
  async execute({ tag, limit }) {
    const matches = (await window.blogSearch.all())
      .filter((item) => (item.tags || []).some((t) => tagKey(t) === tagKey(tag)))
      .sort(byDateDesc);
    const entries = matches.slice(0, limit || 20);
    return {
      content: [{ type: 'text', text: JSON.stringify({ tag, total: matches.length, returned: entries.length, entries }) }],
    };
  },
});

有几个约定要注意。返回值仍按前面说的 content block 给:{ content: [{ type: 'text', text: '...' }] }annotations 是给调用方的提示,readOnlyHint: true 表示这个工具不改任何状态,agent 可以放心调。注销工具靠 AbortControllerregisterTool 的第二个参数是一个选项对象,把 { signal: controller.signal } 传进去,之后 controller.abort() 就摘掉了。tagKey 是我自己的一个归一化函数,它为什么必须存在,放到后面 getTools() 那节讲。

我挂上去的 5 个工具没有一个会改东西。前 4 个的数据都来自那份 /index.json,最后那个读的是当前页的 DOM:

工具 干什么 在哪些页面注册
search_site 全文搜索,复用 Cmd+K 那份 Fuse 索引 每一页
list_recent_entries posts / reading 列最新条目 每一页
list_tags 标签及各自的条目数 每一页
list_entries_by_tag 按标签列条目 每一页
read_current_entry 当前这篇的正文、日期、tags、各级小标题和锚点 只有文章页

最后一行那个「只有文章页」靠的是后端接口拿不到的信息:访客此刻正打开哪一页。下一节展开。

WebMCP 还有一套声明式写法:给 <form>toolname / tooldescription 属性,浏览器按 type="email"required<select> 这些原生语义自己合成 schema。这个博客的搜索是一个 modal 加 JS,没有 <form action="/search/">,用不上这条路。

和把网站做成 MCP server 的区别#

「页内的 MCP server」讲的是相似的那一半,剩下的四处不相似决定了什么时候该用哪个。

先是有没有一个进程。MCP 的 transport 规范(2025-06-18)只定义 stdio 和 Streamable HTTP 两种 transport,两种都要求另一端有个独立进程在跑,JSON-RPC 消息要序列化过去。WebMCP 的 execute 就是页面里的一个闭包,同一个 tab、同一个 JS 上下文,没有端点、没有端口,也没有部署。

认证也要有人管。MCP server 得自己回答「调用方是谁」,Streamable HTTP 那节甚至专门警告服务端必须校验 Origin 头,否则会被 DNS rebinding 从远端网页打进来。前面说的「页面手上有的东西它都有」在这里兑现:WebMCP 的工具跑在访客自己已登录的那个页面里,登录态和 cookie 都是现成的,不必为 agent 再造一套 token 体系。代价是权限边界从服务端搬到了页面上,跨 origin 想放行要靠 registerToolexposedTo 显式声明,默认别的站点和跨源 iframe 看不到你的工具。

工具集能不能中途变化,是我这次感受最明显的一处。MCP server 的工具通常在启动时定死,客户端连上来看到的就是那一套。WebMCP 的工具集跟着页面走:我的 read_current_entry 只在存在 article.post .post-content 的页面注册,所以文章页 getTools() 返回 5 个,首页和列表页返回 4 个,两个数我都实测过。规范里有个 toolchange 事件,就是给这种进出用的。

服务对象不同,这一条最后决定该选哪个。MCP server 是我配给我自己的 agent 的,WebMCP 服务的是访客的 agent,我甚至不知道对面是什么模型。发现方式也跟着分岔:MCP server 靠一条配置就能常驻,WebMCP 必须访客真的打开那一页才知道有工具,Chrome 自己的文档把这条列为已知限制,原话是 “Clients and browsers must visit a site directly to know if it has callable tools”。

所以对这个博客,两件事我分开看:想让我自己的 agent 查博客,写个普通 MCP server 抓 /index.json 更直接,不需要浏览器,也不会跟着 trial 到期;WebMCP 管的是访客那一头,好处不在我自己这里。MCP 本身的 Tools / Resources / Prompts 三个概念在早先那篇 MCP 笔记里用一个 SQLite server 走过一遍,这里不重复。

Hugo 这边动四个文件#

工具的数据源用的是仓库里已经有的东西,没有新建索引。config.toml[outputs] home = ["HTML", "RSS", "JSON"] 加上 layouts/index.json 已经在产出 /index.jsonstatic/js/search.js 也已经拿 Fuse 建好了内存索引。唯一要补的一步是把这份索引暴露出去,因为那个 IIFE 里的 fuse 是私有变量:

const indexReady = initSearch();

window.blogSearch = {
  query: async (query, limit) => {
    await indexReady;
    if (!fuse) throw new Error('Search index unavailable');
    return fuse.search(query).slice(0, limit || 10).map(result => result.item);
  },
  all: async () => {
    await indexReady;
    return searchIndex;
  }
};

两个方法都先 await indexReady,这样 agent 在 /index.json 还没 fetch 回来时调用也不会拿到空结果。

模板那边全站生效的钩子是 layouts/partials/extended_head.html,主题的 head.html 末尾会调它,所以不用碰 themes/(那是只读的 submodule)。这个文件里还夹着 favicon、dashboard.css 那些原有的东西,下面只贴新加的两段:

{{ with .Site.Params.webmcpOriginTrialToken }}
<meta http-equiv="origin-trial" content="{{ . }}">
{{ end }}

{{ with resources.Get "js/webmcp.js" }}
<script src="{{ (. | fingerprint).RelPermalink }}" defer></script>
{{ end }}

token 放 config.toml 的 params 里,没填就不输出那个 meta:

webmcpOriginTrialToken = "AmcgHA/PlRmsNdEE4mHAUlh5t/..."

这张 token 按设计要明文出现在每个访客的 HTML 里,不是密钥,进 git 没问题。想确认手上这张对不对,可以自己解一遍:token 的布局是 1 个版本字节、64 字节签名、4 字节大端的长度,然后才是 JSON payload。我那张解出来是 {"origin":"https://meirong.dev:443","feature":"WebMCP","expiry":1794873600},时间戳换算过来正好是 2026-11-17。

两个让工具注册不上的原因#

这两件事互相独立,任一处出问题,getTools() 就返回空数组。麻烦的地方在于都不抛异常:页面照常渲染,搜索框照常能用,只有 agent 那边什么都看不到。

token 没生效时 document.modelContext 就是 undefined#

这是最容易误判「接完了」的一步:探测那句直接 returnregisterTool() 一次都没执行,页面却看起来一切正常。token 免费,到 developer.chrome.com/origintrials 注册填一个域名就能拿到。

token 还得真的落在 <head> 里才算。我第一次部署完 document.modelContext 仍然是 undefineddocument.querySelector('meta[http-equiv="origin-trial"]') 找得到那个 meta,换成 document.head.querySelector(...) 就找不到了:这个主题的 search partial 在 <head> 里输出了一个 <div>,HTML 解析器碰到它就隐式闭合 head,排在后面的标签全落进了 body,而 Chrome 不认 body 里的 token。把 meta 提到那个 partial 之前就好了。要是你的 head 里也塞了别的东西,值得按 document.head 而不是源码顺序确认一遍。

本地开发可以绕开,chrome://flags/#enable-webmcp-testing 打开重启就行,但那只对开了 flag 的那台机器有效。别人打开线上页面时,document.modelContext 存不存在只看页面里有没有那张 token,读者自己不用为此设置什么。

注册表单里还有一条免责声明:实验特性的用量超过 Chrome 全站页面加载的某个比例,就会被自动关掉。我在那张要登录的表单上看到的是 1%、按 14 天中位数算;公开的 Origin Trials developer guide 给的标准限额是 0.5%,也没写测量窗口。这两个数我没能对上,但哪个都说明这个 API 在 trial 期内可能中途消失,探测那两行不能省。

有依赖的两个脚本,缓存会把它们配错#

webmcp.js 依赖 search.js 先把 window.blogSearch 挂上,两个都是 defer,按文档顺序执行,本地一直是对的。线上第一次真正拿到 token 的那次,console 里是这一行:

[webmcp] window.blogSearch missing (js/search.js did not load); no tools registered

/index.json 和两个脚本都是 200,文件内容也确实是新的。问题出在缓存:这个站在 Cloudflare 后面,DevTools 的 Network 面板里 /js/*.js 的响应头带 cache-control: public, max-age=14400, must-revalidate,而我在部署前访问过这个域名。浏览器于是复用了缓存里的旧 search.js(没有 window.blogSearch),配上新拉的 webmcp.js,两个文件各自都没错,配在一起就是坏的。硬刷新之后 5 个工具立刻正常。

static/ 下的文件名是固定的,所以只要两份文件不是同时更新到浏览器,就有这个窗口。用 _headers 把它们改成不缓存也能压住,代价是每次访问都回源。我选的是搬到 assets/js/ 走 Hugo Pipes 的 fingerprint,内容变则 URL 变,旧的那份不会再被命中:

<script src="/js/search.288d6ea71a5226b63f609ba57a0a3390d1c99572767de220956a0bc1ace092b5.js" defer></script>
<script src="/js/webmcp.<同样一串 64 个十六进制字符的内容哈希>.js" defer></script>

主题自己那个 JS bundle 是用 resources.Get "js/menu.js" 一个一个点名取的,不是通配,所以往 assets/js/ 里加文件不会被它卷进去。我也没加 minify,构建产物和源文件逐字节相同,调试时看到的就是我写的那一份。

getTools() 在线上返回什么#

验证要在页面的主 world 里做。浏览器扩展注入的脚本跑在 isolated world,那里看不到 origin trial 暴露的 API,我第一次就是这么误判成没生效的。往页面插一个 <script> 标签,在里面读结果才准。

文章页上 await document.modelContext.getTools() 返回 5 个工具,名字是按字母序排的,不是我注册的顺序:

["list_entries_by_tag","list_recent_entries","list_tags","read_current_entry","search_site"]

调用一次 read_current_entry,在 /posts/spring-boot-classloader-tccl-matrix/ 这篇上拿到 13287 个字符的正文和 14 个小标题(含各自的锚点 id)。

list_tags 那边则暴露了一个我自己写出来的 bug。它一开始返回 spring-boot 44 篇,而线上 /tags/spring-boot/ 翻完 6 页是 59 篇。差的那 15 篇都在索引里,只是 tag 在这个仓库里有三种拼法:

tags = ["spring-boot", ...]
tags = ["spring boot", ...]
tags = ["Spring Boot", ...]

Hugo 的 taxonomy 会把 term 名 urlize 之后再建页面,所以这三种都归到 /tags/spring-boot/;我的工具只做了 toLowerCase(),于是把带空格的那两种当成另一个 tag。读者在站上看到 59 篇,agent 问过来拿到 44 篇,list_tags 里还会出现两行只差一个连字符的 tag。改成和 urlize 同规则(trim、转小写、空白转连字符)之后三种拼法都返回 59。全仓有 6 个 tag 撞在这条上,折叠后 tag 总数从 405 降到 399。

/index.json 里存的是我手写的原始 tag,站点展示的是 Hugo 归一化之后的结果,中间差了一步。工具要是直接拿原始值去比,答出来的数就和读者看到的页面对不上。

executeTool 有两个地方和我以为的不一样。一是传参,传对象会报 UnknownError: Failed to parse input arguments,得传 JSON 字符串:

await ctx.executeTool(tool, { query: 'external-dns' });                 // UnknownError
await ctx.executeTool(tool, JSON.stringify({ query: 'external-dns' })); // 正常

二是返回值,typeof'string',得先 JSON.parse 一层才能拿到里面的 content[0].text,而那段 text 又是我自己 JSON.stringify 进去的,所以一共要解两次。

annotations 也有一处对不上。我注册时传的是 { readOnlyHint: true, consequentialHint: false }getTools() 读回来变成 { readOnlyHint: true, untrustedContentHint: false }。多出 untrustedContentHint 这一步有解释:imperative API 文档写明 readOnlyHintuntrustedContentHintconsequentialHint 三个 hint 都默认 false,读回来的是补齐默认值之后的那一套。剩下没解释的是 consequentialHint: false 为什么整个从回读里消失了。

读者那边怎么调#

前面说的「访客的 agent」听着抽象。共同的前提有一条:得用 Chrome,且版本落在 trial 的区间里(注册页上写的是 149 到 156),换别的浏览器打开这个站,拿到的就是一份普通 HTML。在这之上,我写这篇时能真的把这些工具调起来的有三条路:一条用 Chrome 自带的 DevTools,另外两条要读者自己装扩展。

最省事的是 Chrome DevTools 里的 WebMCP 面板。它按 agent 看到的样子列出这一页的工具,能看每次调用的入参、返回值和状态(Completed / Canceled / In Progress / Error),还带一块手动测试区,可以绕开模型自己填 JSON 参数执行。文档里没给它列前置条件:不装扩展,页面带着 token 就能在 Application 面板下看到这一栏。你现在读的这一页就注册着前面那张表里的 5 个工具,打开 DevTools 的 Application 面板选 WebMCP,列出来的应该正好是它们。调试自己写的工具,这个基本够了。

想让模型来决定调哪个,可以装 Model Context Tool Inspector。它开一个 side panel 列出当前页的工具,既能手填 JSON 参数执行,也能交给 Gemini 去调。它的 README 给的前置条件是 Chrome 150.0.7861.0 以上,外加在 chrome://flags 打开 “WebMCP for testing”(和前面本地开发用的是同一个 flag);它也写明这不是官方支持的 Google 产品。开了这个 flag 的机器上,页面带不带 token 都有 document.modelContext,所以走这条路的读者其实用不着我这张 token。GoogleChromeLabs 的 webmcp-tools 里还有十几个 demo 站和一个 polyfill,想看别人怎么设计工具可以翻那些。

第三条是把页面的工具接到桌面 MCP 客户端上,靠第三方做的桥,执行仍然发生在浏览器里。我找到两种做法:nathan-gage/webmcp-bridge 是一个 Chrome 扩展加一个本机 CLI,扩展读当前 tab 上注册的工具,经 WebSocket 交给 CLI,再以 stdio 暴露给 MCP 客户端(一份第三方教程把接 Claude Code 的步骤写全了,工具名在客户端那边是 tab-{tabId}:{toolName});littleplato/webmcp-cdp-bridge 不装扩展,改用 CDP,Chrome 要带 --remote-debugging-port=9222 启动,桥直接在 tab 里求值 getTools()execute(),README 列的客户端是 Claude Desktop、Claude Code 和 Cursor。两个我都没试过。还有一处不一致要留意:这两份说明里写的都是 navigator.modelContext,规范和 Chrome 文档现在用的是 document.modelContext

三条路里只有 DevTools 那条不用装东西,可填参数和读返回值的还是人;想让模型来调,读者得先装扩展。所以「打开页面,自己的 agent 就顺手用上」现在还不成立。

小结#

一个 Hugo 静态站接 WebMCP,改动集中在四个文件,没有一处需要后端:数据源就是喂搜索框的那份 /index.json。唯一的结构性改动是两份脚本从 static/ 搬进 assets/js/,交给 Hugo Pipes 做 fingerprint。

花时间的地方全在验证。注册失败和答错数都不抛异常,页面照常渲染、搜索框照常能用,只有 agent 那边看得出来,所以本地打桩跑不出这两类问题,得在真实浏览器里按 getTools() 的返回和线上真实数据各对一遍。

值不值得要看给谁用。让自己的 agent 查这个博客,普通 MCP server 更省事;WebMCP 管的是另一头:读者装了扩展再打开我的文章,他的 agent 就能调 read_current_entry 拿到结构化正文,不用去猜 DOM。多这一步前提,放在这个博客上它就更像一次实验;换成有登录态的应用,「工具跑在用户已有的 session 里」这一点会比省下的那些代码重要得多。

参考资料#