跳到主要内容

工具模型与请求生命周期:defineTool、分类门控、ToolHandler

30 秒导读: 这一章讲清 chrome-devtools-mcp 里两件事——「一个工具是什么」「一次工具调用是怎么被处理的」。前者是一份声明式的数据结构(名字、参数 schema、一个 handler 函数);后者是一条固定的流水线,由 ToolHandler 用一把全服务器共享的锁串行地跑完。搞懂这两点,后面各章(浏览器上下文、快照、输入、响应组装、DevTools 能力)都只是往这个骨架里填肉。

本章 scope 明确:只讲工具的定义形状调用生命周期。至于 McpContext / McpPage 内部长什么样,留给 02;Response 怎么把一堆声明攒成最终文本,留给 05。这里我们把它们当黑盒。


1. 这是什么(零基础也能懂)

一句话定义: MCP(Model Context Protocol,模型上下文协议)服务器对外暴露一堆「工具」给 AI 用;本章讲的是这个项目怎么描述一个工具、怎么接住一次工具调用

先建立直觉。 把整台 MCP 服务器想成一家只有一个操作员的柜台:

  • 菜单 = 所有工具的声明(take_snapshotclicknavigate_page……),每一项写清「叫什么、要填什么参数、按下去干什么」。
  • 柜台操作员 = ToolHandler,它是每个工具的统一接线员:你递进来一张点单(参数),它负责查权限、验参数、拿浏览器、执行、把结果整理好递回去。
  • 只有一个操作员 = 一把全局 Mutex(互斥锁)。因为背后只有一个真实浏览器,不能两个工具同时去点它,所以所有调用排队串行

为什么要有这层抽象? 因为一个工具想要的东西很少(「我要拿到当前页面,然后截个图」),但每次调用共通的杂活很多:这个工具此刻开没开?参数对不对?有没有弹窗挡着?调用完要不要埋点?把这些杂活从每个工具里抽出来、塞进一条统一流水线,工具本身就能写得极短。看一个真实工具有多短:

// src/tools/snapshot.ts:12 —— 一个完整的、真实的工具
export const takeSnapshot = definePageTool({
name: 'take_snapshot',
description: `Take a text snapshot ... (uid) ...`,
annotations: {category: ToolCategory.DEBUGGING, readOnlyHint: false},
schema: {verbose: zod.boolean().optional()..., filePath: zod.string().optional()...},
blockedByDialog: true,
verifyFilesSchema: ['filePath'],
handler: async (request, response) => {
response.includeSnapshot({verbose: request.params.verbose ?? false, filePath: request.params.filePath});
},
});

注意这个 handler 只有一行:它不亲自截图、不查权限、不加锁,只是往 response 上「声明」一句「我要一张快照」。所有难活都在别处。这就是本章要拆开的两台机器:声明工具的工厂(defineTool / definePageTool)和执行调用的流水线(ToolHandler)。


2. 顶层全景(它大概怎么转)

2.1 三个角色

角色干什么在哪个文件
工具定义一份声明数据:name / description / annotations / schema / handler / 两个开关src/tools/ToolDefinition.ts,各 src/tools/*.ts
createTools启动时把所有工具模块聚合成一个数组、按名字排序src/tools/tools.ts:27 createTools
ToolHandler每个工具一个实例;shouldRegister 决定要不要挂上去,handle() 跑一次调用的完整生命周期src/ToolHandler.ts:146 ToolHandler
registerTool(接线)ToolHandler.handle 接到 MCP SDK 的 server.registerToolsrc/index.ts:167 registerTool

2.2 启动期 vs 调用期

整个系统分两个阶段。启动期只跑一次:聚合工具 → 每个工具造一个 ToolHandler → 过得了门控的才注册到 MCP server。调用期每次工具被调用都跑:一条固定流水线。

启动期(createMcpServer 一次)
createTools(args) 收集 + 排序所有工具定义

▼ for each tool
new ToolHandler(tool, args, getContext, toolMutex)

▼ shouldRegister?
┌────┴─────┐
否 是 → server.registerTool(name, {schema, annotations}, handler)
(跳过) │

调用期(每次工具被调用) MCP client 发来 { params }


ToolHandler.handle(params) ← 见 §4 流水线

2.3 一次调用的主线(高层,不进代码)

client 调用 → handle(params)
1. 被门控禁用? → 直接回一条「怎么打开我」的报错
2. 参数有没有多余键? → 直接回「删掉这些参数重试」
3. toolMutex.acquire → 排队,拿到锁(全服务器串行)
4. getContext() → 确保浏览器已启动/连接,拿到 McpContext
5. new McpResponse → 造一个空的「声明式响应」收集器
6. verifyFilesSchema → 对要写文件的参数逐个 validatePath(沙箱校验)
7. page-scoped? → 解析出目标 McpPage(pageId 路由 或 当前选中页)
8. blockedByDialog? → 若有弹窗未处理,抛错拦下
9. tool.handler(...) → 真正干活:往 response 上声明「要什么」
10. response.handle() → 把声明兑现成最终 content(见 05 章)
11. finally: ClearcutLogger 埋点 + 释放锁

后面 §3 讲「工具是什么」(第 0 步之前的东西),§4 逐格讲这条流水线。


3. 一个工具是什么(声明式定义)

3.1 字段清单:BaseToolDefinition

所有工具共享一份基础形状,定义在 src/tools/ToolDefinition.ts:38 BaseToolDefinition:

字段类型作用
namestring工具名,也是 MCP 里注册的键、排序键、埋点键
descriptionstring给模型看的自然语言说明
annotations.categoryToolCategory归属分类,决定门控(§4.1)
annotations.readOnlyHintboolean是否只读(不改环境);注意「写文件」也算不只读
annotations.conditions?string[]额外的实验性开关名,全部为真才启用(§4.1)
schemazod.ZodRawShape参数 schema,用 zod 声明,每个参数带 .describe()
blockedByDialogboolean页面有未处理弹窗时,是否应拒绝执行(§4.4)
verifyFilesSchemaArray<keyof Schema>哪些参数是「文件路径」,调用前要过沙箱校验(§4.3)

ToolDefinition(ToolDefinition.ts:57)在此之上加一个 handler:

// src/tools/ToolDefinition.ts:61 —— handler 的签名
handler: (request: Request<Schema>, response: Response, context: Context) => Promise<void>;

三个参数是工具能拿到的全部世界:request.params(校验后的参数,ToolDefinition.ts:68 Request)、response(声明式响应收集器,ToolDefinition.ts:108 Response)、context(浏览器与文件系统的门面,ToolDefinition.ts:191 Context)。handler 返回 Promise<void>——它不 return 结果,而是往 response 上「声明」要什么,这是理解本项目的关键(详见 05 章)。

3.2 defineTool:一个「几乎什么都不做」的工厂

你可能以为 defineTool 会包装、注册、加校验。它没有——它只是个恒等/透传函数,存在的唯一目的是给 TypeScript 一个锚点做类型推断:

// src/tools/ToolDefinition.ts:339 —— 去掉重载后的真身
export function defineTool(definition) {
if (typeof definition === 'function') { // 工厂形态:定义依赖启动参数
const factory = definition;
return (args) => factory(args); // 原样返回工厂
}
return definition; // 普通形态:原样返回对象
}

为什么支持「函数形态」? 有些工具的定义要根据启动参数(ParsedArguments)变化。例如 list_pages 的描述文案会因为有没有开扩展而不同:

// src/tools/pages.ts:19 —— 工厂形态:args 影响 description
export const listPages = defineTool(args => ({
name: 'list_pages',
description: `Get a list of pages${args?.categoryExtensions ? ' including extension service workers' : ''} ...`,
...
}));

于是工具模块里有两种导出:普通对象(如 selectPage,pages.ts:38)和接受 args 的函数(如 listPages)。谁负责在启动时把函数「喂」进 args?就是下一节的 createTools

3.3 definePageTool:声明「我需要一个页面」

大量工具的第一件事都是「拿到当前页面」。与其每个 handler 都写一遍 context.getSelectedMcpPage(),不如声明式地说「我是 page-scoped 的」,让流水线替我把页面注入进来definePageTool(ToolDefinition.ts:386)做的就是给定义盖一个 pageScoped: true 的戳:

// src/tools/ToolDefinition.ts:403 —— 普通形态:打上 pageScoped 标记
return {...definition, pageScoped: true} as DefinedPageTool<Schema>;

对应地,page-scoped 工具的 handler 签名多了一个 page:

// src/tools/ToolDefinition.ts:365 DefinedPageTool 的 handler
handler: (request: Request<Schema> & {page: ContextPage}, response, context) => Promise<void>;

回头看 §1 那个 take_snapshot:它用 definePageTool,handler 里可以直接 request.page,完全不用自己去 context 里找。page-scoped 与非-page-scoped 的区别,是本章一条重要暗线,§4.5 会讲流水线如何据此分叉。

一句话对照:

非 page-scoped(defineTool)page-scoped(definePageTool)
标记pageScoped: true
handler 拿到request(无 page)、responsecontext额外 request.page
谁负责取页面handler 自己(如 context.getPageById)流水线注入(§4.5)
典型例子list_pages / select_page / new_page(pages.ts)take_snapshot / navigate_page / click

3.4 几个共享 schema 小零件

ToolDefinition.ts 末尾提供几个可复用的 schema 片段,工具用展开语法 ... 拼进自己的 schema:

  • pageIdSchema(:412):{pageId: zod.number()...}注意——它不是每个工具都手写的字段,而是在开了 page-id 路由时由 ToolHandler 自动合并进 page-scoped 工具的 schema(§4.5)。
  • timeoutSchema(:416):一个 timeout 数字参数,带一个 .transform():传 0 或负数时归一化为 undefined(即「用默认超时」)。new_pagenavigate_page...timeoutSchema 进来。
  • viewportTransform(:429):把 "1280x800,mobile,touch" 这种字符串解析成 puppeteer 的 viewport 对象——CLI 参数到结构化配置的转换器。

这些是「声明式」风格的体现:能复用的参数形状,做成积木共享。


4. 一次调用的生命周期(ToolHandler)

现在进入本章核心。每个注册的工具在启动期都被包进一个 ToolHandler 实例(src/ToolHandler.ts:146)。构造函数拿到四样东西:工具定义、启动参数、一个 getContext 惰性取上下文的函数、以及全服务器共享的那把 toolMutex(ToolHandler.ts:152)。

下面按「启动期决定 + 调用期流水线」两段讲。

4.1 门控:这个工具此刻该不该存在?(shouldRegister)

不是所有工具默认都开。有些分类(扩展、第三方、WebMCP)是实验性的,默认关闭:

// src/tools/categories.ts:33
export const OFF_BY_DEFAULT_CATEGORIES = [
ToolCategory.EXTENSIONS, ToolCategory.THIRD_PARTY, ToolCategory.WEBMCP,
];

门控逻辑分两级,都在 getToolStatusInfo(ToolHandler.ts:74)里判:

  1. 分类门控 getCategoryStatus(:39):由分类算出一个 flag 名(buildFlag,:23,如 categoryExtensions)。若分类在「默认关」名单里 → flag 没显式开就禁用;否则(默认开的分类)→ 只有 flag 被显式设成 false 才禁用。
  2. 条件门控 getConditionStatus(:63):遍历 annotations.conditions,任一实验开关未开就禁用。例如 get_tab_id 声明了 conditions: ['experimentalInteropTools'](pages.ts:397),没开这个实验参数它就不出现。

被禁用时,buildDisabledMessage(:27)会拼出一条可操作的报错,告诉用户「用 chrome-devtools start <flag>=true 打开我」。

这个「禁用」结果怎么用?看构造函数:

// src/ToolHandler.ts:158
const {disabled, reason} = getToolStatusInfo(tool, serverArgs);
this.disabledReason = reason;
this.shouldRegister = !(disabled && !serverArgs.viaCli);

两条分支:走 CLI(viaCli)时,即使禁用也照样注册,只是调用时返回那条「怎么打开我」的说明(见 §4.2 第 1 步);非 CLI 时,禁用的工具根本不注册(shouldRegister=false),index.ts:175 直接 return,它对 client 不可见。

4.2 进入 handle():两道「不用加锁」的前置拦截

真正的调用从 handle(params)(ToolHandler.ts:178)开始。头两步刻意在拿锁之前做——因为它们纯是校验,不碰浏览器,能早退就别占用那把宝贵的锁:

第 1 步 · 禁用拦截(:179):若 disabledReason 存在(只有 CLI 模式下会走到这,见 §4.1),直接返回那条说明文字,isError: true

第 2 步 · 未知参数校验(:191):unknownArgumentNames(:172)挑出 params 里不在 schema 的键。有多余键就返回一条 buildUnknownArgumentsMessage(:130)——它会列出「期望的参数」并让模型「删掉多余的重试」。这是给 LLM 的纠错反馈:模型爱瞎传参数,与其静默忽略,不如明确顶回去。

4.3 拿锁、取上下文、造响应

过了前两关,才进入真正的执行区:

// src/ToolHandler.ts:208
const guard = await this.toolMutex.acquire(); // ← 排队,全服务器串行
const startTime = Date.now();
let success = false;
try {
const context = await this.getContext(); // 确保浏览器就绪,拿 McpContext
const response = this.serverArgs.slim
? new SlimMcpResponse(this.serverArgs)
: new McpResponse(this.serverArgs); // 造空的响应收集器

为什么整台服务器只用一把 Mutex? 这是本项目最重要的设计决定之一。背后是一个真实的 Chrome 实例——一次只有一个「选中页面」,DevTools 协议、性能 trace、快照 uid 都是有状态的全局资源。若允许两个工具并发去点同一个浏览器,状态会互相踩踏(比如 A 在导航、B 在读快照,读到半个页面)。所以设计上放弃并发、换取确定性:所有工具调用严格串行。这把锁是 FIFO 的(src/utils/Mutex.ts:22 acquire),先到先服务;guard.dispose()finally 里释放(Mutex.ts:13 Guard.dispose)。

getContext(定义在 index.ts:100)是惰性的:浏览器直到第一次工具调用才启动或连接,之后复用同一个 McpContext。细节留给 02

slim 分支:--slim 模式换用更精简的 SlimMcpResponse,并且如 tools.ts:28 所示,slim 模式下 createTools 只聚合 slim/tools.ts 这一小组工具。slim 是「给上下文预算紧张的场景的最小工具集」。

4.4 文件参数的沙箱校验(verifyFilesSchema)

内层 try(ToolHandler.ts:225)先做文件校验:

// src/ToolHandler.ts:226
if (this.tool.verifyFilesSchema) {
for (const key of this.tool.verifyFilesSchema) {
const filePath = params[key];
await context.validatePath(filePath as string); // 沙箱边界
}
}

凡是声明了「我有文件路径参数」的工具(如 take_snapshotverifyFilesSchema: ['filePath']),这里逐个把对应参数交给 context.validatePath。这是写文件的安全闸:防止工具往沙箱外的任意路径写文件(路径沙箱本身在 02 章)。校验不过会抛错,被下面的 catch 接住变成错误响应。

4.5 页面路由:page-scoped 分叉(核心区别)

接下来是 page-scoped 与否的分叉点——本章反复强调的那条区别,就落在这几行:

// src/ToolHandler.ts:232
if (isPageScopedTool(this.tool)) { // 有 pageScoped:true 戳?
const pageId = typeof params.pageId === 'number' ? params.pageId : undefined;
const page =
this.serverArgs.experimentalPageIdRouting && pageId !== undefined && !this.serverArgs.slim
? context.getPageById(pageId) // 路由到指定页
: context.getSelectedMcpPage(); // 否则用「当前选中页」
response.setPage(page);
if (this.tool.blockedByDialog) {
page.throwIfDialogOpen(); // 有弹窗挡着就拦下
}
await this.tool.handler({params, page}, response, context); // 注入 page
} else {
await this.tool.handler({params}, response, context); // 不注入
}

拆开看:

  1. 怎么选页面? 默认走「当前选中页」getSelectedMcpPage()——大多数会话里只有一个活动页,工具作用于它。只有开了 experimentalPageIdRouting 实验、且调用带了 pageId、且非 slim 时,才按 pageId 路由到具体页 getPageById(pageId)。这就是 §3.4 里 pageIdSchema 的用武之地:构造函数在这个实验开启时,把 pageId 字段自动并进 page-scoped 工具的 schema(ToolHandler.ts:162),工具自己不用声明。
  2. 弹窗门控 blockedByDialog:选好页面后,若工具声明了 blockedByDialog: true,调 page.throwIfDialogOpen()。语义是「页面上有 alert/confirm 之类原生对话框没处理时,别让我执行」——因为弹窗会阻塞页面,截图、点击等操作会挂住。take_snapshot 就是 blockedByDialog: true;而 handle_dialog(处理弹窗的工具本身)自然是 false(pages.ts:354)。
  3. 注入 vs 不注入:page-scoped 分支把解析好的 page 塞进 request 交给 handler;非 page-scoped 分支只给 {params}。这就是为什么 take_snapshot 的 handler 能直接 request.page,而 select_page 得自己 context.getPageById

4.6 执行、兜错、组装、埋点

handler 跑完(或抛错),剩下的是收尾:

  • 兜错(ToolHandler.ts:262):handler 若抛异常,response.setError(err) 把错误记到响应里,让它冒泡——错误也是一种要返回给模型的内容。
  • 数据格式(:266):--experimentalDataFormat 优先,否则回退到旧的 --experimentalToonFormat,再否则 'default'。这个 dataFormat 传给下一步。
  • 组装(:273):response.handle(toolName, context, dataFormat) 把 handler 期间攒下的一堆「声明」(要快照、要网络请求、要截图……)真正兑现成 MCP 的 contentstructuredContent怎么兑现是 05 的主题,这里只需知道:handler 只「点单」,response.handle 才「上菜」。
  • 结果标记(:283):response.error 存在则 isError: true;开了 experimentalStructuredContent 才附带 structuredContent
  • finally 埋点 + 放锁(:306):无论成败,都 ClearcutLogger.get()?.logToolInvocation({toolName, params, schema, success, latencyMs}) 记一次遥测(延迟经 bucketizeLatency 分桶),然后 guard.dispose() 释放锁,让队列里下一个调用进来。

至此一次调用闭环。注意放锁在 finally——handler 再怎么抛,锁一定还回去,不会把整台串行服务器锁死。


5. 接线:ToolHandler 怎么接到 MCP SDK

前面都是本项目自己的机制。最后一步是把它接到 MCP SDK 的标准注册接口上,这发生在 src/index.ts:167 registerTool:

// src/index.ts:167
function registerTool(tool) {
const toolHandler = new ToolHandler(tool, serverArgs, getContext, toolMutex);
if (!toolHandler.shouldRegister) return; // 门控:禁用且非 CLI → 不挂
server.registerTool(
tool.name,
{
description: tool.description,
inputSchema: toolHandler.registeredInputSchema, // 可能含自动并入的 pageId
annotations: tool.annotations,
},
async (params) => toolHandler.handle(params), // ← 每次调用进流水线
);
}

三点值得记:

  1. toolMutex 在循环外只 new 一次(index.ts:165),再传给每个 ToolHandler——这正是「全服务器一把锁」在代码上的落点。
  2. shouldRegister 在这里兑现:禁用工具直接 return,对 client 不可见(§4.1)。
  3. registeredInputSchemaToolHandler 构造时算好的(ToolHandler.ts:169,zod.object(...).passthrough()),可能已并入 pageId——交给 SDK 做协议层的参数声明。SDK 收到调用时回调最后那个箭头函数,一切又汇入 handle(params)

启动时的驱动很朴素(index.ts:192):createTools(serverArgs) 得到排好序的工具数组,for 循环逐个 registerTool。而 createTools(tools.ts:27)本身做三件事:按 slim 与否聚合tools/* 模块(Object.values(...) 摊平)、把函数形态的工具用 args 调用求值(tools.ts:50)、最后按 name 排序(tools.ts:57,localeCompare,让工具列表稳定、可预测)。


6. 巧妙之处(可借鉴的技术)

  • 声明式工具,handler 只「点单」不「做菜」。 handler 返回 void、只往 response 上声明诉求(snapshot.ts:39 一行搞定),把「怎么把诉求兑现成文本」集中到 response.handle。工具因此极短、极好写、极易测。
  • defineTool 是恒等函数,价值全在类型。 它不做运行时包装(ToolDefinition.ts:339),纯为 TS 推断和「对象/工厂」两形态提供统一入口。真正的编排全在 ToolHandlercreateTools,职责不散。
  • pageScoped 一个布尔戳,换来页面注入。 definePageTool 只加 pageScoped:true(ToolDefinition.ts:403),流水线据此在 ToolHandler.ts:232 自动解析并注入 page,顺带接上 pageId 路由和弹窗门控——一处声明,三处受益。
  • 全服务器一把 FIFO Mutex。 用「放弃并发」换「状态确定」,针对「单浏览器、有状态」这一根本约束(ToolHandler.ts:208 + index.ts:165)。锁在 finally 释放,异常不锁死。
  • 门控产出可操作报错。 禁用工具不是静默消失,而是回一句「用 <flag>=true 打开我」(ToolHandler.ts:27 buildDisabledMessage);未知参数回一句「删掉重试」(:130)。都是写给 LLM 看、能自我纠错的反馈。

7. 边界与局限(诚实说明)

  • 无并发:一把全局锁意味着工具调用永远串行,吞吐受限——这是对「单浏览器」约束的有意取舍,不是缺陷。
  • defineTool 不做运行时参数校验:schema 的实际 zod 解析发生在 MCP SDK 注册层(registeredInputSchema),defineTool 本身只透传。
  • 本章不覆盖:McpContext / McpPage 内部、路径沙箱 validatePath 的实现(见 02);response.handle 如何组装内容与各 collector(见 05)。这里都当黑盒。

8. 代码地图(导航索引)

主题文件符号
工具定义的字段形状src/tools/ToolDefinition.tsBaseToolDefinition / ToolDefinition / Request
handler 能拿到的世界src/tools/ToolDefinition.tsResponse / Context / ContextPage
定义工厂(恒等/透传)src/tools/ToolDefinition.tsdefineTool
page-scoped 工厂(打 pageScoped 戳)src/tools/ToolDefinition.tsdefinePageTool / DefinedPageTool
共享 schema 积木src/tools/ToolDefinition.tspageIdSchema / timeoutSchema / viewportTransform
分类枚举与默认关名单src/tools/categories.tsToolCategory / labels / OFF_BY_DEFAULT_CATEGORIES
聚合 + 求值工厂 + 排序src/tools/tools.tscreateTools
门控计算src/ToolHandler.tsgetToolStatusInfo / getCategoryStatus / getConditionStatus / buildFlag / buildDisabledMessage
是否注册 + 自动并 pageIdsrc/ToolHandler.tsToolHandler(constructor)/ shouldRegister / registeredInputSchema
调用生命周期src/ToolHandler.tsToolHandler.handle / unknownArgumentNames / isPageScopedTool
全局串行锁src/utils/Mutex.tsMutex.acquire / Mutex.Guard.dispose
接到 MCP SDKsrc/index.tsregisterTool / getContext(惰性上下文)/ toolMutex
典型 page-scoped 工具src/tools/snapshot.tstakeSnapshot / waitFor
典型非-page-scoped 与工厂形态src/tools/pages.tslistPages / selectPage / newPage / getTabId(conditions 门控)