我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

手写MCP协议层:用Playwright封装七个浏览器工具赋能AI代理

手写MCP协议层:用Playwright封装七个浏览器工具赋能AI代理 1. CLI工具为什么需要MCP先把这笔账算明白这几年AI编程工具火到什么程度做过实际项目的人都清楚。Codex CLI、Claude这类终端代理已经不只是补代码了它们会自己查资料、改文件、跑测试甚至能帮你把整套发布流程走完。但真正用起来之后你会发现一个尴尬的事实你的CLI工具再强大AI代理也“看不见”它。命令行程序天然是给人用的输出格式靠人眼判断参数含义靠help文档解释状态靠人脑记忆这些东西在AI代理眼里全都不存在。你当然可以让代理用shell来猜比如让它执行curl或者python脚本去解析网页但这条路非常脆弱。一旦输出格式变化、编码异常、超时未返回你的prompt就废了。我自己之前就踩过这种坑让代理去读某个服务的状态它从systemctl status里截取了一段错误关键词结果把警告当成了致命错误白白折腾了半小时。问题的根子在于传统CLI缺少两样东西一是“自我描述”它没法告诉代理自己有什么能力、参数长什么样二是“结构化返回”它只会吐文本不会告诉代理这条数据是对象的哪个字段。这正好是MCP要解决的。MCP的全称是Model Context Protocol你可以把它理解成给AI代理装了一个“USB-C接口”。在这个协议下你的CLI工具或者任何服务都可以把自己封装成一个MCP Server对外暴露工具列表、输入参数定义、返回值结构。AI代理作为MCP Host不需要预先知道你工具的内部逻辑只要按协议发一个tools/list就能拿到完整的能力清单再按tools/call去调用。整个过程是结构化的JSON往来不再需要一边看文档一边猜输出。今天这篇东西记录的就是我干过的一件比较激进的实践不引现成的MCP SDK纯手写协议层在一天之内把一套浏览器自动化能力封装成七个工具接入到支持MCP的CLI代理里。下文会拆开讲讲为什么选择手写、协议层内部是什么样、七个浏览器工具怎么设计、最终怎么跑通以及中间踩过的那些坑。2. 协议层的设计思路为什么不直接上现成框架我决定手写MCP协议层的时候很多人第一反应是你疯了吧社区里明明有现成的SDK比如官方提供的TypeScript SDK、Python SDK装上之后几行代码就能起一个MCP服务。这话没错但实际动手的时候你会发现现成方案在特定场景下可能会成为负担。先说我自己的处境。我需要的是多种工具在一个进程里被集中调用而且七个浏览器工具要共享同一个浏览器实例避免每次调用都重新拉起Chromium。用官方SDK当然可以做到但SDK的抽象层级并不总是跟业务贴合尤其在参数校验、错误码、传输边界这些地方我还要再去包一层“适配器”。SDK更新频率高版本一变依赖树经常跟着变。如果只是做个小工具还要管理一整套依赖锁文件多少有点得不偿失。另外一个现实原因是手写协议层可以让我完全掌握协议细节。MCP基于JSON-RPC 2.0核心方法其实没几个initialize、notifications/initialized、tools/list、tools/call另外还有resources和prompts我这里用不到就没实现。这意味着整个协议交互过程是完全可以被“看穿”的。如果某个请求出了问题我只要打开stderr日志就能看到客户端到底发了什么服务端回了什么。换成SDK这中间多了一层黑盒。手写协议层的核心其实就是一个从标准输入读JSON、处理请求、把响应写到标准输出的循环。MCP的stdio传输方式是每行一个JSON消息没有长度前缀所以用readline就能搞定。听起来很简单但真正容易翻车的点在于绝对不能让任何调试日志混进stdout。所有console.log打出来的东西都会被MCP Host当成协议消息去解析一旦混入普通文本整个通信就直接断裂。正确的做法是所有调试信息一律走stderr或者写到独立日志文件。消息格式方面协议层的任务集中在两个地方。第一个是方法路由根据method字段分发到不同处理函数。第二个是id关联请求和响应靠id一一对应这样客户端可以并发发多个请求不会乱。我这里因为没有多路并发的强需求采用顺序处理但依然保留了id的映射逻辑因为后续可能扩展。协议层大致结构如下const readline require(readline); const rl readline.createInterface({ input: process.stdin }); function reply(id, result) { process.stdout.write(JSON.stringify({ jsonrpc: 2.0, id, result }) \n); } rl.on(line, async (line) { let msg; try { msg JSON.parse(line); } catch (e) { process.stderr.write(无法解析JSON消息: ${line}\n); return; } try { if (msg.method initialize) { reply(msg.id, { protocolVersion: 2024-11-05, capabilities: { tools: {} }, serverInfo: { name: browser-tools-mcp, version: 1.0.0 } }); } else if (msg.method tools/list) { reply(msg.id, { tools: getToolDefinitions() }); } else if (msg.method tools/call) { const result await callTool(msg.params.name, msg.params.arguments); reply(msg.id, { content: [{ type: text, text: JSON.stringify(result) }] }); } else { reply(msg.id, { content: [{ type: text, text: 未知方法 }] }); } } catch (err) { process.stderr.write(处理请求出错: ${err.stack}\n); reply(msg.id, { content: [{ type: text, text: 执行失败: ${err.message} }], isError: true }); } });这段代码就是整个MCP服务的“骨架”。你可能注意到返回值统一包了一层content数组里面是文本类型的内容块。这是MCP规范要求的返回结构Host拿到这层结构之后会把text字段展示给模型。如果你返回的是结构化数据记得先JSON序列化再塞进text里这样模型才能准确理解。3. 七个浏览器工具的拆解与落地工具列表是MCP Server的灵魂。Host拿到工具列表之后会把每个工具的名字、描述、参数Schema交给模型让模型决定什么时候调用哪个工具。所以工具描述必须写得像“给一个聪明但没眼睛的实习生看”说清楚这个工具能做什么、什么时候该用、参数填什么。我当时定的七个工具涵盖了浏览器自动化里最常用的几类操作工具名称功能典型使用场景browser_screenshot整页或视口截图让代理“看到”页面视觉效果browser_dom_snapshot抓取DOM树摘要分析页面结构、定位元素browser_extract_content提取正文并转Markdown快速总结文章内容browser_click按CSS选择器点击元素模拟用户点击操作browser_type向输入框填入文本表单填写、搜索操作browser_console_logs收集页面Console日志排查前端报错browser_execute_js在页面执行任意脚本深度定制页面行为设计原则就一句话每个工具解决一类问题不要一个工具干太多事。最开始我把“点击后截图”合并成了一个工具结果发现参数和返回结构都变得很不干净后来果断拆开。代理完全可以先调browser_click再调browser_screenshot两个步骤反而更灵活。3.1 浏览器实例的全局管理在聊每个工具之前有个前置设计必须交代清楚浏览器实例怎么管理。如果每调用一次工具就launch一个Chromium实例那七次工具调用就是七个进程慢不说内存还容易爆。我的做法是在进程启动时懒加载一个单例浏览器第一次需要时创建后续复用。const { chromium } require(playwright); let browser; let page; async function getPage() { if (!browser) { browser await chromium.launch({ headless: true }); page await browser.newPage(); page.setDefaultTimeout(15000); page.on(console, (msg) { console.error([浏览器控制台] ${msg.type()}: ${msg.text()}); }); console.error([browser-tools] 浏览器实例已启动); } return page; }注意日志全部走console.error这是前面提到的原则在这个实现里已经贯彻到底。3.2 工具一页面截图截图是最直观、最能让你确认“代理真的看到了页面”的工具。Playwright的screenshot接口本身支持fullPage参数我把这个选项暴露出来让代理决定是截当前视口还是截全屏长图。async function browserScreenshot(args) { const page await getPage(); await page.goto(args.url, { waitUntil: networkidle }); const path /tmp/browser_shot_${Date.now()}.png; await page.screenshot({ path, fullPage: args.fullPage || false }); return { ok: true, path, note: 所有截图文件默认存放在/tmp目录 }; }这里有一个细节值得说访问args.url之前最好统一加上https://前缀的判断否则代理拿到example.com这种不带协议的地址时goto会提“无法识别协议名”直接报错。在工具内部做一次简单规范化能省去很多来回排查。3.3 工具二DOM快照截图能让代理“看到”视觉但视觉信息在模型推理里成本很高而且可读的文本边界有限。DOM快照则是把页面解析出来的元素摘要返回给模型让它能基于真实结构做下一步操作。async function browserDomSnapshot(args) { const page await getPage(); await page.goto(args.url, { waitUntil: domcontentloaded }); const snapshot await page.evaluate(() { const items []; const queue [document.body]; let depth 0; while (queue.length items.length 2000) { const node queue.shift(); if (!node || node.nodeType ! 1) continue; const tag node.tagName.toLowerCase(); if ([script, style, link, meta].includes(tag)) continue; items.push({ tag, id: node.id || , cls: typeof node.className string ? node.className : , text: (node.textContent || ).trim().slice(0, 80) }); for (const child of node.children) queue.push(child); } return items; }); return { ok: true, nodeCount: snapshot.length, snapshot }; }这里的关键是控制返回体量。一个复杂页面的DOM节点可能是几千甚至上万直接全量返回会直接把上下文窗口塞爆。所以我做了两个限制节点上限2000个、文本截断80个字符。这样拿到的是一个“有重点的结构摘要”而不是原样复刻。3.4 工具三正文提取与Markdown转换这个工具的价值在于当代理需要“读一篇文章”“总结某段内容”的时候没必要把整份DOM快照都丢给它。提取正文并转成Markdown信息密度更高上下文占用更少。async function browserExtractContent(args) { const page await getPage(); await page.goto(args.url, { waitUntil: networkidle }); const data await page.evaluate(() { const candidates document.querySelector( article, main, .content, #content, [rolemain] ) || document.body; const walker document.createTreeWalker(candidates, NodeFilter.SHOW_TEXT); const lines []; while (walker.nextNode()) { const text walker.currentNode.textContent.trim(); if (text) lines.push(text); } return { title: document.title, content: lines.join(\n) }; }); return { ok: true, title: data.title, content: data.content.slice(0, 20000) }; }这个实现反向选择了document.createTreeWalker而不是直接innerText原因在于TreeWalker可以逐段提取可见文本得到的Markdown段落更干净模型读起来也不容易被大量空白行干扰。20000字符上限需要按实际上下文尺寸动态调整。3.5 工具四和五点击与输入这两个放在一起说因为它们的逻辑几乎可以镜像。一个负责点击一个负责填表都是通过CSS选择器定位元素。async function browserClick(args) { const page await getPage(); if (args.url) await page.goto(args.url, { waitUntil: domcontentloaded }); await page.locator(args.selector).first().click({ timeout: 10000 }); await page.waitForTimeout(800); return { ok: true, clicked: args.selector, url: page.url() }; } async function browserType(args) { const page await getPage(); if (args.url) await page.goto(args.url, { waitUntil: domcontentloaded }); await page.locator(args.selector).first().fill(args.text); return { ok: true, filled: args.selector }; }我在设计参数时让url字段可选。代理如果已经通过前面的browser_screenshot或browser_dom_snapshot访问过这个页面那它可以直接传选择器操作不用重新加载效率高很多。但这里有个状态问题如果页面因为跳转导致元素被重新渲染选择器可能失效。所以我采用.first()加显式超时尽可能降低这种风险。3.6 工具六Console日志抓取前端调试时浏览器控制台的报错信息极其关键。代理在执行某个操作之后如果发现页面表现异常可以调用这个工具来获取最近收集到的控制台日志。let consoleLogs []; async function enableLogCollection() { const page await getPage(); page.removeAllListeners(console); page.on(console, (msg) { consoleLogs.push({ type: msg.type(), text: msg.text(), at: new Date().toISOString() }); console.error([console] ${msg.type()}: ${msg.text()}); }); } async function browserConsoleLogs(args) { await enableLogCollection(); const tail args.tail || 50; const recent consoleLogs.slice(-tail); return { ok: true, count: recent.length, logs: recent }; }注意page.removeAllListeners(console)这一步很关键它避免了重复绑定导致日志堆积。我在前面的getPage()函数里也做过一次console监听但那里只往stderr写没有存入内存。到了这个工具用removeAllListeners清理旧监听后再挂一个新的监听每次采集的都是最新日志。如果你发现代理拿到的日志总是重复或者有缺失多半是监听器挂重了。3.7 工具七执行任意脚本最后一个工具也是能力边界最大的一个在页面上下文里执行任意JavaScript。这个工具很灵活但也最危险因为它相当于把浏览器的完整执行能力交给模型。async function browserExecuteJs(args) { const page await getPage(); if (args.url) await page.goto(args.url, { waitUntil: domcontentloaded }); const result await page.evaluate((script) { const fn new Function(script); return { type: success, value: String(fn()) }; }, args.script); return { ok: true, result }; }封装成new Function而不是直接eval是因为new Function的变量作用域不会泄漏到页面全局作用域降低污染风险。另外返回结果统一用String()序列化避免出现undefined、null、函数对象等不可直接传递的值。4. 把七个工具挂到MCP服务上代码整合与调用链七个工具实现完之后还差一步把定义注册到协议层的分发逻辑上。我这里维护一个数组每一项包含工具名称、描述、参数Schema、处理函数映射。const tools [ { name: browser_screenshot, description: 打开指定URL并截图支持整页截图, inputSchema: { type: object, properties: { url: { type: string, description: 要截图的网页地址 }, fullPage: { type: boolean, description: 是否截取完整页面默认false } }, required: [url] } }, // 其余工具定义省略结构完全一致 ]; const handlers { browser_screenshot: browserScreenshot, browser_dom_snapshot: browserDomSnapshot, browser_extract_content: browserExtractContent, browser_click: browserClick, browser_type: browserType, browser_console_logs: browserConsoleLogs, browser_execute_js: browserExecuteJs }; function getToolDefinitions() { return tools; } async function callTool(name, args {}) { const handler handlers[name]; if (!handler) throw new Error(未知工具: ${name}); return handler(args); }到这里一个可用的MCP Server就完成了。在启动服务之前还要处理一件事进程退出时兜底关闭浏览器实例否则开发过程中会留下僵尸Chromium进程。加个process.on(exit)监听process.on(exit, async () { if (browser) await browser.close(); });整个调用链现在是这样的MCP Host比如Codex CLI - 标准的stdio JSON-RPC请求 - 你的MCP Server - 分发到七个工具之一 - Playwright操作浏览器实例 - 结构化结果返回给Host。链路比较长但每层职责单一定位问题也容易。验证服务是否正常工作可以先把协议层的握手流程跑一遍。我习惯用一行echo直接模拟客户端请求echo {jsonrpc:2.0,id:1,method:initialize,params:{}} | node mcp-server.js正常时会看到一行JSON输出包含serverInfo和capabilities。接下来验证工具列表echo {jsonrpc:2.0,id:2,method:tools/list,params:{}} | node mcp-server.js如果tools/list能正常返回七个工具的定义说明协议层和工具注册逻辑已经通了。之后再用工具实际发起一次浏览器操作看看浏览器能不能正常启动。我在这个阶段就发现了依赖缺失的问题后面会专门讲。5. 与CLI代理的集成Codex CLI配置实操写完了服务接下来就是把服务注册到CLI代理里。以Codex CLI为例MCP Server配置写在~/.codex/config.toml文件里。先确认这个文件存在没有就用codex命令初始化一次。在配置文件里添加如下内容[mcp_servers.browser-tools] command node args [/绝对路径/mcp-server.js]值得注意的地方有两个第一command和args里的路径都要写绝对路径尤其是Node.js的路径如果你用的是nvm之类的版本管理器node命令在你的shell里可用但Codex CLI启动子进程时未必会加载你的shell配置。第二如果MCP Server依赖某个环境变量可以在env字段里显式声明[mcp_servers.browser-tools] command /home/user/.nvm/versions/node/v20.11.0/bin/node args [/home/user/browser-tools/mcp-server.js] env { PLAYWRIGHT_BROWSERS_PATH /home/user/.cache/ms-playwright }配置完成后重启Codex CLI。在交互界面里输入“列出所有已连接的MCP server”如果能看到browser-tools说明连接成功。然后试着给一句话指令打开 https://example.com截一张整页截图保存后告诉我文件路径正常情况下Codex会先解析到browser_screenshot这个工具填充URL参数和fullPage: true调用工具然后根据返回的path字段告诉你结果。这个过程看起来像变魔术但背后就是protocol层的一次标准JSON往返。如果你用的CLI代理支持MCP但配置文件格式不同比如某些工具要求通过命令行参数注入运维思路是一样的。核心是先确认MCP Server能独立跑通stdio协议再解决Host侧的连接参数。6. 实操中踩过的坑与解决思路手写协议层、整合CLI代理这个过程并非一帆风顺。我把这几天实际遇到的问题整理成了一个排查表按频率排序问题现象常见原因解决办法MCP连接失败Host提示cannot connectstdout被console.log污染所有调试日志改走stderrtools/list为空工具定义未写入getToolDefinitions检查工具注册数组是否完整代理说找不到codex cli binaryPATH没有包含codex可执行文件所在目录用which codex确认路径在配置里写绝对路径browsers启动失败Playwright浏览器内核未安装执行npx playwright install --with-deps chromium页面打开超时网络慢或者页面依赖大量外部资源将waitUntil改为domcontentloaded并设置超时时间内存占用过大每次工具调用创建新Page未关闭复用Page实例或按域创建独立Context代理连续调用工具之间状态丢失页面跳转导致元素失效每个操作前重新用DOM快照定位元素这里有两个坑值得展开细说。第一个是stdout污染的坑。MCP的stdio传输是逐行JSON如果服务端任何地方用了console.log(服务已启动)Host解析这行时就会直接抛错。我当时在browser初始化逻辑里放了一行console.log打印浏览器版本结果Codex CLI每次连接都是“握手失败”。排查了很久最后是打开stderr日志才定位到。这类问题只看Host侧日志很难发现因为Host只会告诉你“无法解析服务端响应”。第二个是浏览器内核缺失的问题。Playwright安装后需要单独下载Chromium内核很多人npm install playwright之后就直接跑了结果launch报找不到浏览器。解决方法是执行npx playwright install chromium或者用--with-deps参数同时安装系统依赖。这在Linux服务器上尤其常见因为缺的往往是系统库文件光装浏览器内核不够。另外还有一个关于CLI代理的常见疑问MCP和RAG到底有什么区别。很多人会把它们混为一谈其实这两个东西是不同层面的。RAG是“给模型找知识”它的核心是检索MCP是“给模型开工具”它的核心是接口。RAG回答的问题是“你有没有这份资料”MCP回答的问题是“你能不能执行这个操作”。浏览器自动化这个场景基本用不上RAG因为页面本身就是实时数据的来源只要工具能拿到DOM、能执行脚本模型就能从中提取所需信息。从设计上把工具能力做好比塞一堆背景知识进去有用得多。7. 这只是一个开始MCP的路还很长写到这里整个项目从零到上线的过程基本复盘完了。如果你问我手写协议层到底值不值我的答案是在这个场景下非常值。因为你会在写协议的过程中真正理解MCP的边界在哪里它为什么设计成JSON-RPC为什么返回值要包一层content为什么stdout只能走协议数据。这些认知不是靠读文档能获得的必须亲手踩一遍坑才有感觉。我个人在实际操作中的体会是MCP的价值不在于“协议有多先进”而在于它重新定义了工具和AI代理之间的关系。过去我们写CLI工具默认的使用者是一个懂命令行的人现在有了MCP工具的使用者变成了一台会推理的机器它不需要你看文档只需要你把能力说得清清楚楚。这个转变其实是对工具设计方式的一次很大冲击值得每个工具作者都认真想一想如果你的工具只能被AI代理调用你会怎么设计它的参数和返回值最后再分享一个小技巧。我后来给这套服务加了一个非常简单的“操作回放”能力每个工具在真正执行前都会把参数和时间戳写入一个本地JSONL文件。这样在出问题时你不仅能看到最终结果还能复盘整个调用序列知道代理是一步一步怎么把浏览器“开到”那个状态的。对调试多步自动化来说这个文件可比聊天记录好用多了。这个习惯我一直留着也推荐给你。
返回列表