# Step 50: 接入真实 MCP server —— @playwright/mcp 驱动浏览器
一句话导读:把 step47-49 手写的那个 ~130 行教学版 MCP 客户端,直接拿去对接微软工业级的
@playwright/mcp,一行客户端代码不改就驱动了真实 chromium——这是对「协议即互操作性」最硬的验证。
# 一、这一步做了什么(What)
前面 step47-49 我们手写了一个最小 MCP 客户端(stdio + 换行分帧的 JSON-RPC),并配了一个自己写的玩具 server(exampleServer.mjs,就 add/echo 两个工具)。这一步做的事出奇地小:
- 在
MCP_SERVERS里多加一行配置,指向真实的@playwright/mcp; - 把请求超时从 10s 调到 120s,把 MCP 工具的引擎层超时设成
timeoutMs:0; - 启动,然后
/mcp就看到浏览器 server 推来的 23 个工具(browser_navigate、browser_evaluate……)。
启动时两个 server 一起连:demo 的 2 个 + browser 的 23 个,主程序工具数从 11 一路飙到 36。browser_navigate https://example.com 真的拉起 chromium 导航,browser_evaluate (()=>document.title) 返回 "Example Domain"。
# 二、面试官视角:为什么这一步「几乎不用改代码」反而是重点?(Why)
面试题:你手写的 MCP 客户端和微软官方的 playwright server 从没「对接调试」过,凭什么它们能直接互通?这背后靠的是什么?
靠的是 MCP 是一个标准协议——而标准协议的全部价值,就是让实现方彼此不需要认识。
对比一下没有协议的世界:如果浏览器自动化能力是我用一个私有 SDK 暴露的,那我的客户端就得针对它的方法名、参数格式、错误码一一适配;换一个 server(比如数据库工具)又得重写一遍适配层——这是 M 客户端 × N server 的组合爆炸。
MCP 把它拍平成 M + N:只要双方都说 initialize 握手、tools/list 列工具、tools/call 调用这套 JSON-RPC,谁实现的谁就能被任何合规客户端驱动。所以「不用改代码」不是偷懒,恰恰是协议设计成功的证明——我们的客户端面对玩具 server 和工业级 server,看到的都只是「一批带 schema 的工具」,底下是谁根本不关心。
反过来这一步也顺带验证了:我们那个 130 行的教学客户端真的合规,不是「能连自己写的 server」的自娱自乐。
# 三、原理:它是怎么工作的(How)
# 数据流
index.ts 启动 → 遍历 MCP_SERVERS → 每个 new McpClient(cfg).connect()
↓(browser 这条)
spawn "cmd /c npx -y @playwright/mcp" → 子进程 stdin/stdout 当双向管道
↓ initialize 握手 → notifications/initialized → tools/list
拿回 23 个 McpToolDef → mcpToolDefs() 包装成 mcp__browser__* 的 ToolDef
↓ 并入主 ToolRegistry(工具数 → 36)
模型调用 mcp__browser__browser_navigate({url})
↓ ToolDef.execute → client.callTool("browser_navigate", input)
↓ request("tools/call") 走 stdio 发给 playwright 子进程
chromium 真的导航 → content 结果原路返回 → 交给模型
2
3
4
5
6
7
8
9
10
# 真实的配置就三行
services/mcp/client.ts 里,接入一个工业级 server 的全部改动是这样的:
export const MCP_SERVERS: McpServerConfig[] = [
{ name: "demo", command: "node", args: ["services/mcp/exampleServer.mjs"] },
// ★[step50] 接入真实 MCP server:本机的 @playwright/mcp(浏览器自动化)。
// Windows 上必须用 cmd /c 才能 spawn npx(直接 spawn "npx" 会 ENOENT)。
{ name: "browser", command: "cmd", args: ["/c", "npx", "-y", "@playwright/mcp"] },
];
2
3
4
5
6
工具包装那一层完全复用 step48 就写好的 mcpToolDefs,命名沿用真实 Claude Code 的约定 mcp__<server>__<tool>,避免和本地工具或别的 server 撞名:
return client.tools.map((t) => ({
name: `mcp__${client.name}__${t.name}`,
inputSchema: normalizeSchema(t.inputSchema),
timeoutMs: 0, // ★[step50] 引擎层超时关掉,改由 client 的 120s 兜底
async execute(input) {
const text = await client.callTool(t.name, input);
return { content: [{ type: "text", text: text || "(无输出)" }] };
},
}));
2
3
4
5
6
7
8
9
# 题眼:超时要「分层」
请求超时被从 10s 提到 120s,写在 request() 里:
private request(method: string, params?: any, timeoutMs = 120000): Promise<any> { ... }
浏览器操作(起 chromium、等页面加载、等元素)动辄十几秒,10s 太短会误杀。但这里有个陷阱:引擎层自己还有一个 30s 的工具超时。如果不处理,client 等着 120s 兜底,引擎却在 30s 就先把工具判死了。所以 mcpToolDefs 里给每个 MCP 工具设 timeoutMs: 0——关掉引擎层超时,让慢工具的生死统一由 client 的 120s 说了算。这就是「超时分层」:内层(引擎)让位,外层(client)兜底,避免两个计时器打架。
# 四、深入追问(面试常见 follow-up)
Q:为什么 Windows 上必须写 command:"cmd", args:["/c","npx",...],直接 spawn "npx" 不行?
A:Node 的 spawn 在 Windows 上默认不走 shell,而 npx 在 Windows 上是个 .cmd 批处理脚本(npx.cmd),不是可直接执行的二进制。直接 spawn("npx") 找不到可执行文件就 ENOENT。用 cmd /c npx ... 是让 Windows 的命令解释器去解析 npx.cmd。真实 Claude Code 的 .claude.json 里配 MCP server 也是这个写法,不是我们的特殊 hack。
Q:timeoutMs:0 和把它设成 999999 有什么区别?为什么不干脆两层都设成 120s?
A:语义清晰。0 表示「本层不管超时」,把职责单一化地交给 client——只有一个地方在计时,出问题时你知道去哪儿看。两层都设长超时会有两个计时器,一旦行为诡异(比如工具卡住到底是谁先超时的),排查成本翻倍。
Q:playwright 的工具 schema 那么复杂,模型自动调用会不会有兼容问题?
A:会,这是本步的已知限制。playwright 工具的 schema 嵌套很深,而我们的 normalizeSchema 只给顶层 object 补 additionalProperties: false。让 DeepSeek 自动调某些浏览器工具时,个别深层 schema 可能被代理判 400。手动 /mcp call 不走模型 schema 校验,所以不受影响。这提示一个真实工程点:schema 归一化要递归处理,才能覆盖工业级 server 的复杂结构。
Q:每次启动都要 spawn playwright,慢怎么办?
A:npx -y @playwright/mcp 首次会下载包,调浏览器工具还要 chromium(npx playwright install)。之后有缓存但每次启动仍要 spawn 子进程、连接,startup 慢几秒。不需要时把 browser 从 MCP_SERVERS 注释掉即可——这也印证了配置驱动的好处:加/减一个能力就是加/减一行。
# 五、踩坑 / 设计权衡
三个坑都真实踩过并解决:
- spawn npx 在 Windows ENOENT →
cmd /c包一层(见上)。 - 浏览器操作慢 → 请求超时 10s→120s + 引擎层
timeoutMs:0,两层配合。 - 首次要下载 →
npx -y首次拉包 + chromium 需单独 install,第一次跑会明显卡。
权衡上,我们没有做的是「从配置文件加载 server」「SSE/HTTP transport」「OAuth 鉴权」「工具级权限」——这些真实版都有,但对「理解 MCP 本质」不是必需,留到 step51 再补 transport。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
MCP_SERVERS 写死 | 从 .claude.json / .mcp.json 读用户配置的 mcpServers |
| 连任意 stdio server | 一致(我们和真实 CC 连的是同一个 @playwright/mcp) |
| 手写客户端驱动 | 真实用官方 @modelcontextprotocol/sdk(step51 换) |
cmd /c npx spawn | 完全相同(真实 CC 在 Windows 也这么写) |
| 还缺:配置文件加载、SSE/HTTP、OAuth、工具级权限 | 真实版都有 |
# 七、一句话总结
接一个工业级 MCP server = 加一行配置 + 调好超时分层:能「不改一行客户端代码」就驱动微软的 playwright,正是标准协议 M+N 红利的兑现;而 Windows 的 cmd /c npx 和 120s/timeoutMs:0 的双层超时,是把「教学玩具」推向「真实世界」时必然撞上的边界细节。
# 下一节预告
手写客户端能连真实 server 已经证明它合规,但只支持 stdio 且不够健壮。step51 用官方 @modelcontextprotocol/sdk 重写客户端内部,换引擎不换接口。