最近我在排查一个 H5 白屏问题时,搭了一条基于 vConsole MCP 的调试链路,效果比我预想的好。以前调真机页面,要么连数据线开 Remote Debug,要么在页面里塞一堆 console.log 然后截图回传,过程又慢又容易漏。现在我直接把 vConsole 捕获的日志、网络请求、报错堆栈通过 MCP 暴露给 AI 客户端,AI 真的能“看见”页面运行时发生了什么,甚至能帮我定位到具体某一行代码的兼容性问题。
这篇文章就从我的实操出发,聊聊这套链路怎么设计、怎么落地、踩过哪些坑。它适合谁看?如果你经常被 H5 真机问题折磨,或者正在做 AI 辅助前端调试的工具,又或者只是好奇 MCP 除了查资料还能干点什么,这篇都应该能给你一点参考。我会把页面端采集、MCP server 实现、AI 工具契约、真实 debug 案例都拆开讲,尽量让你照着也能搭一套出来。
1. vConsole 不只是手机上的绿色悬浮按钮,更是一套现成的采集器
1.1 vConsole 面板背后到底采集了什么
很多前端对 vConsole 的认知还停留在“真机调试面板”,觉得它就是个绿色小按钮,点开能看到 Log、Network、Element 这些 Tab。这个理解没错,但如果只把它当成面板,就浪费了它真正的价值——vConsole 在渲染面板的同时,其实已经在底层做了一整套运行时数据的捕获。
vConsole 的 Log 面板不是凭空出现的,它启动时会重写console.log / console.info / console.warn / console.error / console.debug,把每次调用的时间、类型、序列化后的内容存进内存。Network 面板则是通过包装XMLHttpRequest和fetch,记录请求 URL、Method、状态码、耗时、请求参数和响应体。除此之外还有 System 面板的 UA、分辨率、语言环境,Storage 面板的 localStorage / Cookie 快照,以及可选的 Performance 面板等。
也就是说,只要页面里执行过new VConsole(),它就天然变成了一个“运行时数据采集器”。我们可以不关心面板 UI,直接把它的数据源抽出来用。
1.2 为什么站在 vConsole 的肩膀上,而不是自己埋一套
你可能想:这点数据我自己埋点不也能拿到吗?确实能,但成本完全不同。自己采集会遇到一堆麻烦:
console重写需要考虑apply绑定、循环引用、Symbol和BigInt的序列化。- 网络捕获要同时兼容
fetch和XMLHttpRequest,还得处理fetch的Request对象形态差异。 - 绑定时机、重复注入、多实例共存都要处理,越写越像一个小框架。
vConsole 已经被大量项目验证过,稳定性不用操心。我的做法是:页面里正常引入 vConsole,同时再注入一小段“桥接采集脚本”,它监听 vConsole 的内部事件,把日志和请求实时导出到调试通道。这样面板保留,数据也能流出去。
1.3 一个必须提前说清的边界:“任何 H5”是有条件的
标题说“任何 H5”,这是产品化表述,实际落地时大家要清楚边界。vConsole 是注入式的,你必须能控制目标页面的源码,或者至少能在页面加载前执行你的脚本。自家项目、内部系统的 H5 页面没问题,但线上别人的网页你不能直接注入,除非借助 DevTools 协议之类的底层方案,那属于另一个维度的工程量。
另外一个边界是环境。vConsole 主要解决移动端 H5 真机问题,如果你的场景是纯桌面端 Chrome,直接用 DevTools 就好,没必要绕一圈。vConsole MCP 的舒适区,恰恰是那些你没法打开 DevTools 的场景——微信内置浏览器、App 内嵌 WebView、各种安卓 ROM 自带浏览器。
2. MCP 在这里的真正作用:把页面端的内存数据翻译给 AI 听
2.1 为什么需要 MCP,而不是随手写个 HTTP 接口
我先说结论:MCP 解决的是“AI 客户端怎么稳定、规范地调用你的工具”这件事。如果只是自己写个 HTTP 接口,再用 AI 客户端的 HTTP 能力去访问,也能跑通,但会有几个问题:
- 接口地址、鉴权方式、数据格式都是临时的,AI 客户端没法自动发现。
- 没有统一的工具描述规范,AI 不知道这个接口是干嘛的、参数怎么传、什么时候该调用。
- 换一个 AI 客户端,整套对接逻辑可能要重写。
MCP 的全称是 Model Context Protocol,本质是一个基于 JSON-RPC 2.0 的开放协议,让 AI 客户端以标准方式发现和调用外部工具。你可以把它理解成“AI 世界的 USB-C 接口”——只要双方都支持协议,插上就能用,不用为每个外设单独设计接口。我们这里的外设,就是“H5 页面的运行时数据”。
2.2 工具调用和数据暴露的两种姿势:tools 与 resources
MCP 里有两类能力值得关注:tools和resources。简单区分:tools是让 AI “做一件事”,有输入有输出,比如“获取最近 20 条日志”;resources是让 AI “读一份内容”,更像静态文件,比如“读取当前页面完整状态报告”。
对这个场景,我的建议是优先用tools实现,而不是resources。原因是日志和请求是高度动态的数据,每次读取时需要的条件(条数、级别、关键字)都不一样,天然适合“请求-响应”模式。resources适合那种内容相对固定的长报告,比如“把最近 100 条日志整理成 Markdown 总结”。实际使用中,我把两种都用上了:AI 主动排查时用get_recent_logs、get_recent_requests查数据,最后阶段会让 AI 调用一个generate_debug_report工具生成完整问题报告,作为资源再读一遍。
2.3 数据从页面到 AI 的路由方式
明确一个关键点:AI 并不会直接开浏览器去看页面。MCP server 在这里是“中间人”,它本身不跑在 H5 页面里,而是运行在你的电脑上(或局域网服务器),页面端采集到的日志通过 WebSocket 或 HTTP 上报给它,MCP server 暂存在内存环形队列里,等 AI 通过工具来拉取。
串起来就是一条链路:
- H5 页面加载采集脚本,开始捕获
console和网络请求。 - 采集脚本把结构化日志批量推送给本机 MCP server 的接收端点。
- AI 客户端通过 MCP 协议调用
get_recent_logs、get_recent_requests。 - MCP server 从内存缓冲里取出数据,返回给 AI。
- AI 结合日志和请求时序,给出问题定位。
这里有个很多人会踩的坑:一开始我把数据推倒成“页面直接把日志写进 localStorage,AI 再读”,结果发现跨上下文读取不可靠。后来改成“推送到独立 MCP server”才稳定。别让页面端承担 AI 通信的职责,各干各的,职责清晰,后续换采集器也容易。
3. 从零搭一套 vConsole MCP:页面采集与 MCP server 的核心实现
3.1 总体架构设计
在动手写代码之前,先明确要拆成哪几个部分。整个工程我分成三块:
- 页面端采集 loader,负责挂载 vConsole、捕获日志/请求、上报数据。
- 接收服务(挂在 MCP server 内部),接收上报并写入内存缓冲。
- MCP 工具层,定义工具 schema,让 AI 能查询。
运行时,AI 客户端通过 stdio 或 SSE 连接 MCP server。如果你是在电脑上调试手机页面,手机和电脑必须在同一局域网,一般我会用 SSE 模式,让页面的采集脚本通过http://localhost:3000/ingest上报,MCP server 同时监听这个 HTTP 端点。
3.2 页面端采集脚本:关键实现
页面端我封装了一个collector.js,核心思路是两段 hook:一段接管console方法,一段接管fetch/XMLHttpRequest。先看console部分:
(() => { if (window.__vconsoleMcpInjected) return; window.__vconsoleMcpInjected = true; const originalMethods = {}; ["log", "info", "warn", "error", "debug"].forEach((method) => { originalMethods[method] = console[method].bind(console); }); const queue = []; let timer = null; function safeSerialize(args) { try { return args.map((arg) => { if (typeof arg === "object" && arg !== null) { return JSON.parse(JSON.stringify(arg, (key, value) => { if (typeof value === "bigint") return `${value} (BigInt)`; if (value instanceof Error) return { name: value.name, message: value.message, stack: value.stack }; return value; })); } return arg; }); } catch (e) { return "[Unserializable]"; } } function pushLog(level, args) { queue.push({ type: "log", level, time: Date.now(), content: safeSerialize(args), }); scheduleFlush(); } function scheduleFlush() { if (timer) return; timer = setTimeout(() => { timer = null; flush(); }, 500); } function flush() { if (!queue.length) return; const batch = queue.splice(0, queue.length); if (navigator.sendBeacon) { navigator.sendBeacon("/ingest", JSON.stringify({ logs: batch })); } else { fetch("/ingest", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ logs: batch }), keepalive: true, }).catch(() => {}); } } ["log", "info", "warn", "error", "debug"].forEach((method) => { console[method] = (...args) => { originalMethods[method](...args); pushLog(method, args); }; }); })();这里有几个容易忽略的细节:console的原始方法必须先bind出来,否则有些环境的console.log内部依赖this,直接调用会报错;序列化必须单独处理Error对象和循环引用,否则日志全变成[object Object];批量上报用sendBeacon优先,能在页面卸载时尽量把日志发出去。
再来看网络请求部分:
(() => { const originalFetch = window.fetch; if (!originalFetch) return; window.fetch = async (...args) => { const start = Date.now(); let input = args[0]; let init = args[1] || {}; const url = typeof input === "string" ? input : input.url; const method = (init.method || (input.method ? input.method : "GET")).toUpperCase(); try { const response = await originalFetch(...args); const duration = Date.now() - start; const record = { type: "request", url, method, status: response.status, duration, time: Date.now(), requestHeaders: sanitizeHeaders(init.headers), responseText: await safeReadResponse(response.clone()), }; sendRequest(record); return response; } catch (err) { sendRequest({ type: "request", url, method, status: 0, duration: Date.now() - start, time: Date.now(), error: err.message, }); throw err; } }; function safeReadResponse(resp) { return resp.text().then((text) => text.slice(0, 2000)).catch(() => "[Unreadable]"); } function sanitizeHeaders(headers) { if (!headers) return {}; try { if (headers instanceof Headers) { const obj = {}; headers.forEach((v, k) => { if (!/authorization|cookie|token/i.test(k)) obj[k] = v; }); return obj; } return { ...headers }; } catch (e) { return {}; } } function sendRequest(record) { navigator.sendBeacon("/ingest", JSON.stringify({ requests: [record] })); } })();XMLHttpRequest的 hook 逻辑也类似,核心是在open、send、readystatechange这几个环节记录信息。我自己实现的时候发现fetch的response一旦被读取过,后续内层逻辑就可能拿不到响应体,所以一定要用response.clone()再读。这个坑在集成测试时非常隐蔽,页面表现正常,但采集数据里响应体全是空的。
3.3 MCP server 实现:用 TypeScript 定义 AI 可用的调试工具
MCP server 我用官方 TypeScript SDK 写的,整体骨架如下(以 SDK 的常规写法为例,版本不同会有 API 差异,以你实际安装的版本为准):
import express from "express"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { z } from "zod"; const logBuffer = []; const requestBuffer = []; const MAX_LOG_COUNT = 500; const MAX_REQUEST_COUNT = 200; const app = express(); app.use(express.json({ limit: "2mb" })); app.post("/ingest", (req, res) => { const { logs, requests } = req.body || {}; if (Array.isArray(logs)) { logBuffer.push(...logs); if (logBuffer.length > MAX_LOG_COUNT) logBuffer.splice(0, logBuffer.length - MAX_LOG_COUNT); } if (Array.isArray(requests)) { requestBuffer.push(...requests); if (requestBuffer.length > MAX_REQUEST_COUNT) requestBuffer.splice(0, requestBuffer.length - MAX_REQUEST_COUNT); } res.json({ ok: true }); }); const server = new McpServer({ name: "vconsole-mcp-server", version: "1.0.0", }); server.registerTool( "get_recent_logs", { title: "获取最近日志", description: "从页面采集器中获取最近的 console 日志,可按级别过滤,级别包括 debug/log/info/warn/error。返回数组包含时间、级别和序列化后的日志内容。适合在排查 H5 页面报错、白屏问题时首先调用。", inputSchema: { type: "object", properties: { limit: { type: "number", description: "返回条数,默认 50,最大 200" }, level: { type: "string", enum: ["debug", "log", "info", "warn", "error"], description: "按日志级别过滤,不传则不过滤", }, keyword: { type: "string", description: "按关键字过滤日志内容" }, }, }, }, async (input) => { let result = logBuffer; if (input.level) result = result.filter((item) => item.level === input.level); if (input.keyword) { const kw = String(input.keyword).toLowerCase(); result = result.filter((item) => JSON.stringify(item.content).toLowerCase().includes(kw)); } const rows = result.slice(-(input.limit || 50)); return { content: [ { type: "text", text: JSON.stringify({ ok: true, count: rows.length, logs: rows }, null, 2), }, ], }; } ); server.registerTool( "get_recent_requests", { title: "获取最近网络请求", description: "从页面采集器中获取最近捕获的 fetch/XMLHttpRequest 请求记录,返回请求 URL、方法、状态码、耗时和脱敏后的请求头等信息。适合排查接口报错、跨域、请求失败等问题。", inputSchema: { type: "object", properties: { limit: { type: "number", description: "返回条数,默认 30,最大 100" }, onlyFailed: { type: "boolean", description: "只返回失败请求(状态码 >= 400 或请求异常)" }, }, }, }, async (input) => { let result = requestBuffer; if (input.onlyFailed) { result = result.filter((item) => item.status >= 400 || item.error); } const rows = result.slice(-(input.limit || 30)); return { content: [ { type: "text", text: JSON.stringify({ ok: true, count: rows.length, requests: rows }, null, 2), }, ], }; } ); server.registerTool( "clear_debug_buffer", { title: "清空调试缓冲", description: "清空当前 MCP server 内存中的所有日志和请求缓冲,在开始一轮新问题排查前调用,避免旧数据干扰判断。", inputSchema: { type: "object", properties: {} }, }, async () => { logBuffer.length = 0; requestBuffer.length = 0; return { content: [{ type: "text", text: "cleared" }] }; } ); // SSE 传输:启动 HTTP 服务 const transport = new SSEServerTransport({ server }); app.get("/sse", (req, res) => { const sseTransport = new SSEServerTransport({ server }); sseTransport.onClose(() => sseTransport.server.close()); // 初始化连接后处理 client message }); app.listen(3000, () => { console.log("vconsole MCP server listening on 3000"); });上面这段在真实落地时还要处理一点:SSE 的SSEServerTransport每个客户端连接需要绑定独立 transport,同一个 MCP server 实例在不同 transport 上连接会相互干扰。这个我在后面专门讲坑。
3.4 接入你的 AI 客户端
MCP server 跑起来后,剩下的事情就是把 AI 客户端指向它。不同客户端的配置方式不一样,但一般都是在 MCP 配置里加一条:
{ "mcpServers": { "vconsole": { "command": "node", "args": ["/path/to/vconsole-mcp-server/dist/index.js"], "env": {} } } }如果你用的是 stdio 模式,上面这样就行。如果手机页面要上报到这个 server,你更可能使用 SSE 模式,那么配置里一般填 URL。这里我不展开某个特定客户端的配置,因为各家格式常有更新,重点是理解原理:所有 MCP 客户端本质都是“启动进程或连上 URL,用 JSON-RPC 通信”。
3.5 我在实现阶段踩过的三个坑
首先要说的是 SDK 版本差异。MCP SDK 迭代很快,我最早照着旧版示例写的server.tool(name, schema, handler),升级后变成server.registerTool(name, schema, handler),中间还经历过McpServer构造函数参数变化。如果你照着网上的教程写,一定要看清楚对应版本。我的建议是直接看本地node_modules里的类型定义,别猜。
其次是 SSE 连接数问题。很多人在本地用 stdio 模式一切正常,一到手机上报场景换 SSE,就出现 AI 客户端能连上但页面数据推不进来的情况。原因在于 SSE 是一条客户端发起的持久连接,页面端上报用的普通 POST 是另一条通道,两者要分开处理。别把/ingest的 HTTP 服务和 SSE 混在一个 handler 里。
第三个坑是数据量。如果不加限制,一个稍微复杂的页面几分钟就能产生几千条日志,AI 一次拿太多会把上下文撑爆。所以环形缓冲必须做,工具返回也要做截断。另外响应体默认只保留前 2000 字符,避免把整个接口返回塞进上下文。
4. 让 AI 从“看到日志”变成“会 debug”:工具契约与真实案例
4.1 工具描述写得好不好,直接影响 AI 的诊断质量
很多人以为 MCP 工具只要把数据返回给 AI 就行,但实际效果差别很大。关键在于 inputSchema 里的description——AI 是根据这段描述来决定“什么时候调用这个工具”的。
我一开始把get_recent_logs的描述写成“获取日志”,AI 经常不主动调用。后来改成“从页面采集器中获取最近的 console 日志,适合在排查 H5 页面报错、白屏问题时首先调用”,AI 在遇到“白屏”这类问题时就会优先调用它。同理,get_recent_requests的描述里我加了“适合排查接口报错、跨域、请求失败”,这样 AI 看到报错信息里带网络相关字眼时,会自然调用。
描述里的关键词越贴近实际排查场景,AI 的调用策略就越聪明。这是一条值得反复打磨的经验。
4.2 真实案例一:安卓 WebView 白屏
某次内测,运营反馈“某个安卓机型打开活动页直接白屏”。我把工具链接好后,对 AI 说:“用户反馈页面白屏,你帮我看看现在页面里的日志和请求。”AI 先调用了get_recent_logs,结果里有一条Uncaught SyntaxError: Unexpected token '?',紧接着调用get_recent_requests,发现页面入口 HTML 请求是 200,说明资源本身没挂。
顺着这条 SyntaxError,AI 指出可能是某个第三方脚本在低版本安卓 WebView 上不支持可选链语法,或者 polyfill 没生效。我打开 sourcemap 一核对,果然是一个依赖库在旧内核上解析失败。整个排查过程不到三分钟,换成以前手动复现,我得先想办法拿到那台真机,再看 DevTools,耗时至少半小时。
4.3 真实案例二:接口 500 与 mixed content 拦截
另一个案例更典型。用户反馈某个 H5 页面功能提交失败,但页面没有明显报错。AI 看日志发现有一条Mixed Content: The page at 'https://...' was loaded over HTTPS, but requested an insecure resource 'http://...',再看get_recent_requests,对应接口状态确实是 0,请求根本没发出去。
AI 的判断是“页面本身没问题,是这个接口地址写死了 http,导致被浏览器拦截”。我改了一个字符,把接口地址换成 https,问题解决。这种问题如果人肉查,看控制台也未必一眼注意到,但 AI 在拿到日志和请求的交叉对比后,定位速度确实比我快。
4.4 我常用的提示词模板
实际使用中,不需要每次把工具参数背给 AI 听,MCP 客户端一般会把工具声明注入给 AI。只要给一个明确的目标和约束就行,比如:
页面白屏,请先查看 get_recent_logs 里的 error 级别日志,再结合 get_recent_requests 判断接口是否有异常。优先怀疑 JS 运行时报错、入口资源加载失败、接口阻塞这三个方向。如果要开始新一轮排查,记得先调用clear_debug_buffer,否则上一轮的数据会干扰判断。我自己把“清空缓冲”作为排查起步的标准动作,效果比在提示词里说“忽略旧日志”可靠得多。
5. 上线运行后的几个现实问题与后续改进方向
5.1 日志就是隐私,别把调试通道裸露到公网
这是我必须放在最前面提醒的。日志里可能包含接口入参、响应体、用户操作上下文,一旦 MCP server 暴露到公网,等于把这些数据直接送出去。我自己的做法是:默认只绑定127.0.0.1,手机和电脑同局域网调试时,用动态生成的访问令牌做鉴权;绝不把authorization、cookie这类敏感头原样上报,上线前设置脱敏规则。
生产环境要不要开这套能力,我的建议是默认关闭,除非你在做线上问题复现,否则没必要冒这个风险。
5.2 性能不能失控
日志采集本身有开销,尤其consolehook 和fetch包装会影响到页面性能。我比较克制:只在需要排查时才在生产页面里临时开启,开关通过 URL 参数或远程配置控制;默认不上报响应体,只记录状态码和耗时;日志上报做批处理,500ms 一刷,而不是每条都触发一次请求。
MCP server 侧同样要设上限。我上面的代码里日志缓冲 500 条、请求缓冲 200 条,够用了。如果你要排查长时间运行的单页应用,建议按时间维度裁剪,而不是无脑堆条数。
5.3 多页面场景怎么区分
实际使用中,手机可能同时打开了好几个 H5 页面,所有日志都混在一个缓冲里会导致 AI 判断混乱。解决方案很简单:采集脚本上报时带上sessionId,MCP server 按 sessionId 分桶,工具参数里增加sessionId可选字段。我是在 URL 参数里传的?vconsoleMcpSession=xxx,页面采集脚本读取后上报,server 自动按这个字段分组。
5.4 后续值得扩展的方向
这套链路跑通后,我发现可扩展空间还很大。比如把 vConsole 换成一套更轻的采集器,减少对第三方依赖;比如接入 sourcemap,让 AI 看到的不是压缩后的代码位置,而是源码行号;再比如把页面路由变化、性能指标(FCP、LCP)也纳入采集范围,让 AI 能排查的维度从“报错和接口”扩展到“性能问题”。
我自己最想做的是把“一键复现”串进来:AI 定位到某段代码后,直接在生产包对应的 sourcemap 里定位具体函数,输出一个最小复现片段。听起来有点远,但现在已经能看到路径了。
最后分享一个我实际使用中的小技巧:不要每次都把 vConsole 面板开给用户看,面板信息太杂,用户也看不懂。现在我只让页面静默加载采集脚本,面板都不一定渲染,AI 看到的反而是结构化、干净得多的日志流。这一层的“去 UI 化”,让整个调试体验提升了一个档次。