跳到主要内容

数据截至 (上游 commit fd0b7e1d9ed9)

第 1 章:工具的定义、注册与执行

这章讲什么: 一个工具在这个项目里长什么样、怎么被注册进 MCP、怎么被类别开关挡掉,以及一次调用在 ToolHandler.handle 里到底经过哪十步。


1.1 一个工具的最小形状

它要解决的小问题

56 个工具,每个都要:声明参数(给模型看)、声明是否只读(给客户端做权限提示)、声明属于哪个类别(给用户做开关)、声明哪些参数是文件路径(要做沙箱校验)、还要能被 CLI 生成器读懂。

如果每个工具各写各的,这些横切关注点就会散落一地。项目的做法是把它们全部塞进一个声明式对象

字段表

定义见 src/tools/ToolDefinition.ts:55BaseToolDefinition:

字段类型干什么
namestringMCP 工具名,同时也是 CLI 子命令名
descriptionstring直接进模型上下文的说明文字
annotations.categoryToolCategory决定默认开关与禁用提示
annotations.readOnlyHintboolean是否不改变环境
annotations.conditionsstring[]额外的实验开关名(如 experimentalVision)
schemazod raw shape参数定义,同时用于遥测脱敏与 CLI 生成
blockedByDialogboolean页面上有未处理对话框时是否直接拒绝
verifyFilesSchema部分映射哪些参数是文件路径、本地/远程分别要不要校验

两个定义函数

defineTool(src/tools/ToolDefinition.ts:372)与 definePageTool(同文件 :414)的区别只有一点:

defineTool → handler(request, response, context)
definePageTool → handler(request & {page}, response, context)
并在对象上打一个 pageScoped: true 标记

pageScoped 的意义: ToolHandler 看到这个标记,就会替你把"当前选中页"解析好并注入,还会在 blockedByDialog 为真时替你检查对话框(src/ToolHandler.ts:310-322)。所以页面类工具的 handler 里从来不写"先拿页面"这段样板。

一个真实工具

click 的定义(src/tools/input.ts:84)几乎就是上面那张表的填空:

export const click = definePageTool({
name: 'click',
description: `Clicks on the provided element`,
annotations: {category: ToolCategory.INPUT, readOnlyHint: false},
schema: {uid: zod.string().describe('The uid of an element ...'), /* … */},
blockedByDialog: true,
verifyFilesSchema: {},
handler: async (request, response) => { /* … */ },
});

工厂形式:描述文字可以随开关变

defineTool 还有一个重载:传一个 (args) => ToolDefinition 的工厂。这让工具描述能随 CLI 开关变化,例如 listPages(src/tools/pages.ts:19)在开了扩展类别时,描述会多一句 "including extension service workers"。

同理 evaluateScript(src/tools/script.ts:17)在开了扩展类别时,schema 里才会多出 serviceWorkerId 参数。参数表本身是条件编译出来的,模型不会看到永远用不上的参数。


1.2 类别与开关:哪些工具默认不出现

思路

56 个工具全塞给模型,既费 token 又容易误用。所以工具被分成 11 个类别(src/tools/categories.ts:7ToolCategory),每个类别对应一个 CLI 开关 --category<Name>,由 buildFlag(src/ToolHandler.ts:31)把类别名转成开关名。

默认开 vs 默认关

OFF_BY_DEFAULT_CATEGORIES (src/tools/categories.ts:35)
├── extensions 扩展管理
├── experimentalThirdParty 页面自带的开发者工具
├── experimentalWebmcp WebMCP
└── pwa 渐进式 Web 应用

其余类别(input / navigation / emulation / performance /
network / debugging / memory)默认开,可用 --no-categoryXxx 关掉

判定逻辑在 getCategoryStatus(src/ToolHandler.ts:47):默认关的类别看"标志是否为真",默认开的类别看"标志是否被显式设为 false"。

禁用之后的两种处理

这里有个容易漏掉的分叉(src/ToolHandler.ts:240):

运行形态被禁用的工具原因
MCP server根本不注册,模型看不见省上下文
CLI(--viaCli)注册,但调用时返回一句提示CLI 的命令表是预生成的静态文件,命令必须存在

提示文案由 buildDisabledMessage(src/ToolHandler.ts:35)生成,内容是"这个工具属于 X 类别,用 chrome-devtools start --categoryX=true 打开"——告诉你怎么修,而不只是说不行

slim 模式:另一套工具表

createTools(src/tools/tools.ts:28)开头就分叉:带 --slim 时只导出 src/tools/slim/tools.ts 里的三个工具(screenshot / navigate / evaluate),完全不加载其余模块。这是给"只想让 agent 干点简单浏览器活"的场景准备的最小面。

最后 tools.sort((a, b) => a.name.localeCompare(b.name)) 按名字排序——工具顺序稳定,这对做提示词缓存的客户端有意义。


1.3 一次调用的十步

全景

ToolHandler.handle(src/ToolHandler.ts:258)是整个 server 里最该读的一个方法。它的结构是:两道前置闸门 → 加锁 → 主体 → finally 埋点。

handle(params)

├─① 工具被禁用? → 返回 disabledReason,isError [闸门]
├─② 有未知参数? → 返回 "Unknown arguments…Remove them" [闸门]

├─③ await toolMutex.acquire() ← 全局串行从这里开始

│ ├─④ getContext() 懒启动/连接浏览器,必要时重建 context
│ ├─⑤ new McpResponse(args) 或 SlimMcpResponse
│ ├─⑥ validateToolFiles() 校验参数里的文件路径
│ ├─⑦ pageScoped? → 解析页面、检查对话框、注入 page
│ ├─⑧ await tool.handler(...) ← 真正的业务
│ │ 出错 → response.setError(err),不抛出
│ ├─⑨ getDevToolsData()(500ms 超时)+ 解析数据格式
│ └─⑩ response.handle() → {content, structuredContent}

└─finally: 上报遥测 + 释放锁

② 未知参数为什么要自己查

注册给 SDK 的 schema 是 zod.object(this.inputSchema).passthrough()(src/ToolHandler.ts:249)——故意放行未知字段,不让 zod 直接报一句晦涩的校验错误。然后自己用 unknownArgumentNames(:245)找出多余字段,拼一句人话:

Unknown argument for tool "click": "selector". Expected arguments: "uid", "dblClick", "includeSnapshot". Remove it and retry.

把"期望的参数列表"和"该怎么改"一起写进错误里,模型下一轮就能自己修正。这是 docs/design-principles.md 里 "Self-Healing Errors" 的直接落地。

③ 一把锁管全部

toolMutex 建在 createMcpServer 里(src/index.ts:198),所有工具共享一个实例。拿的是 Puppeteer 内部的 Mutex,用 Symbol.dispose 释放。

代价是没有并发;收益是所有涉及"当前选中页"的隐式状态都不会打架。考虑到浏览器本身就是单一共享资源,这个取舍是合理的。

但要注意:锁是在 getContext() 之前拿的,所以第一次调用会在锁内启动 Chrome。项目为此专门给 roots 请求加了 5 秒超时(src/index.ts:43ROOTS_REQUEST_TIMEOUT),注释写得很清楚:客户端如果协商了 roots 却不回应,不加超时就会用 SDK 默认的 60 秒把所有工具卡住。

⑧ handler 抛错为什么不算失败

注意第 8 步的内层 try/catch:handler 抛出的错误被 response.setError(err) 吃掉(src/ToolHandler.ts:340-342),然后流程继续往下走到渲染。

效果是:即使动作失败,响应里仍然带着页面列表、控制台消息、当前快照等上下文。 模型拿到的是"失败了 + 现在页面是这样",而不是一句干巴巴的报错。只有 handler 之外的异常(比如浏览器根本连不上)才走外层 catch 返回纯错误。

外层 catch 还会把 err.cause.message 拼进去(:369-371)——很多错误是 new Error(msg, {cause}) 包出来的,不展开 cause 就丢了真正的原因。

⑥ 文件路径校验

validateToolFiles(src/ToolHandler.ts:198)根据工具的 verifyFilesSchema 决定校验哪些参数:

verifyFilesSchema 写法含义
{filePath: true}不论本地还是远程浏览器都校验
{filePaths: {local: true, remote: false}}只有本地浏览器才校验

第二种用在 upload_file(src/tools/input.ts:457):远程浏览器要上传的文件在远端主机上,MCP server 本地根本没有这个路径,校验反而会误伤。

"本地"的判定是 isLocalBrowser(src/ToolHandler.ts:171):进程是自己启动的,或者 websocket 端点指向 localhost。校验本身交给 context.validatePath,细节见第 2 章。

finally:埋点先于释放锁

const context = buildContext(devToolsData, pageUrl);
void ClearcutLogger.get()?.logToolInvocation({
toolName: this.tool.name, params, schema: this.inputSchema,
success, latencyMs: bucketizeLatency(Date.now() - startTime), context,
});
guard[Symbol.dispose]();

注意它把 schema 也传了进去——因为遥测要按参数类型做脱敏(字符串只上报长度分桶、数组只上报个数),没有 schema 就不知道每个参数是什么类型。这段脱敏逻辑见 src/telemetry/transformation.ts:168sanitizeParams,第 8 章会展开。


1.4 原理演示:一个极简的同构实现

下面这段浓缩了本章的骨架,帮你建立直觉。

// 示意,非源码
const tools = [
{name: 'click', schema: {uid: 'string'}, category: 'input',
pageScoped: true, handler: async (req, res) => { /* … */ }},
];

const mutex = new Mutex();

function register(tool, flags) {
if (isDisabled(tool.category, flags)) return; // 类别门禁:直接不注册
server.registerTool(tool.name, {schema: tool.schema}, async params => {
const bad = Object.keys(params).filter(k => !(k in tool.schema));
if (bad.length) return help(tool, bad); // 未知参数 → 可自愈错误
using _ = await mutex.acquire(); // 全局串行
const ctx = await getContext(); // 懒启动浏览器
const res = new McpResponse();
const req = tool.pageScoped ? {params, page: ctx.selectedPage()} : {params};
try { await tool.handler(req, res, ctx); } catch (e) { res.setError(e); }
return res.render(ctx); // 集中渲染
});
}

重点看三处:门禁在注册期锁在调用期handler 的错误不中断渲染


1.5 关键细节与坑

  • registerTool 传的 schema 与 handler 内部用的不是同一个。 开了 --experimentalPageIdRouting 且非 slim 时,页面类工具的 schema 会多注入一个 pageId(src/ToolHandler.ts:242-248),让工具可以直接指定操作哪个页面而不是"当前选中页"。
  • getDevToolsData 带 500ms 硬超时(src/McpContext.ts:431-447)。它要去 DevTools 前端页面里 evaluate 读"用户当前在 Elements 面板选中了什么",这一步可能很慢或不存在;超时就当没有,绝不拖垮工具调用。
  • 数据格式有历史包袱。 --experimentalDataFormat 优先,回退到旧的 --experimentalToonFormat(src/ToolHandler.ts:345-351),这是一处兼容旧开关的分支。
  • readOnlyHint 不总是直觉的。 take_snapshottake_screenshot 都标了 readOnlyHint: false,注释写明原因:因为有 filePath 参数会写盘(src/tools/snapshot.ts:20src/tools/screenshot.ts:140)。

1.6 代码地图

主题文件路径符号名
工具定义原语src/tools/ToolDefinition.tsdefineTooldefinePageToolBaseToolDefinitiontimeoutSchema
类别与默认开关src/tools/categories.tsToolCategorylabelsOFF_BY_DEFAULT_CATEGORIES
工具汇总与 slim 分叉src/tools/tools.tscreateTools
调用总控src/ToolHandler.tsToolHandlerhandlegetToolStatusInfobuildDisabledMessagevalidateToolFiles
注册与锁src/index.tscreateMcpServerregisterToolROOTS_REQUEST_TIMEOUT
输入类工具样板src/tools/input.tsclickfillfillFormpressKeyuploadFile
slim 三件套src/tools/slim/tools.tsscreenshotnavigateevaluate