AI 说"我帮你读一下这个文件",背后不是一句自然语言。
客户端先问服务器支持什么,再列出工具,最后才发一次调用。
我把这条链路做成一个可点的网页,3 次往返、6 条请求/响应,外加 1 条初始化通知。
页面跑了 12 条断言,最后一条失败路径也留了入口。

一、先看结论:6 条消息,3 次往返
MCP 是 Model Context Protocol 的缩写。
它把"模型要调一个外部工具"拆成客户端和服务器之间的 JSON-RPC 2.0 消息。
一次最简单的工具调用,页面里正好 6 条请求/响应。
| 序号 | 方向 | 方法 | 作用 |
|---|---|---|---|
| 1 | 客户端 → 服务器 | initialize | 报协议版本、客户端能力和身份 |
| 2 | 服务器 → 客户端 | initialize | 回协议版本、服务器能力和身份 |
| 3 | 客户端 → 服务器 | tools/list | 问"你有哪些工具" |
| 4 | 服务器 → 客户端 | tools/list | 回 3 个工具和参数结构 |
| 5 | 客户端 → 服务器 | tools/call | 指定工具名和参数 |
| 6 | 服务器 → 客户端 | tools/call | 回内容、isError 和 trace |
第 2 条和第 3 条之间还有一条 notifications/initialized。
它是通知,没有 id,也不占一次往返。
所以页面统计写的是"6 条消息、3 次往返",通知单独用一条窄条显示。
二、为什么值得看:文档里的箭头,落到字段上很容易混
官方文档给的是时序图。
箭头一画,看起来每步都清楚。
真到写客户端时,问题会变成很具体的几件事。
initialize 到底谁先报版本。
服务器回的版本和客户端要的不一致怎么办。
tools/list 返回的 inputSchema 长什么样。
tools/call 的失败,是协议错误还是工具执行错误。
这四个问题,光看箭头答不上来。
我把它们放进同一屏:左边改场景,中间看消息,右边看结果。
三、为什么把这件事做成网页
静态图只能看顺序,看不到字段在哪一步出现。
网页可以把 6 条消息逐条展开,还能把权限门放在调用之前。
我想验证的不是"能不能画一张图"。
我想验证的是:改一个路径,服务器的判定会不会跟着变。
正常路径读 docs/readme.md。
越权路径读 ../../etc/passwd。
未知工具调 convert_video。
三个场景共用同一套 initialize 和 tools/list,只换最后一次 tools/call。
这样对比才干净。
码道有三种使用方式:WebUI(浏览器对话)、TUI(终端命令行)和桌面 IDE(IDE 插件)。本文用 WebUI 版。
四、这轮码道实际做了什么
我给码道的需求写得很细。
单文件 index.html,不要外部 CDN、字体和图片。
左侧权限配置,中间消息流水,右侧结果和统计,底部 12 条自检。
码道交付了一个 32 KB 左右的单文件页面。
首版能跑,但有两处逻辑不对。
第一处是自检第 10 条。
它把三个场景的 id 拼成一个数组再判递增。
实际拿到的是 [1,1,2,2,3,3,1,1,2,2,3,3,1,1,2,2,3,3],页面显示 11/12。
请求 id 本来就应该在每个场景内从 1 开始。
改成只取每个场景的客户端请求,结果才是 normal[1,2,3] forbidden[1,2,3] unknown[1,2,3]。
第二处更隐蔽。
越权场景首版返回的是 JSON-RPC error 对象。
第 7 条断言写的是"isError 为 true",判断的却是"有没有 error 对象"。
测试是绿的,语义是错的。
MCP 里,工具执行失败应该放在 result 里,用 isError: true 表示。
协议层错误才走 JSON-RPC error。
我把越权场景改成 result 结构,错误码放进 _meta.errorCode,第 7 条才真的在检查 isError。
界面也修了两处。
工具白名单里的 run_query 在 300px 左栏被拆成 run_que 和 ry,改成三个小标签。
右侧结果面板原本会随页面滚走,改成 sticky,滚到 tools/call 时还能对照结果。

五、6 条消息逐条拆
第 1 条是 initialize 请求。
jsonrpc 是 2.0,id 是 1。
params 里有 protocolVersion、capabilities 和 clientInfo。
页面里客户端报的版本是 2025-06-18。
capabilities 是空对象,表示这个客户端暂时不需要服务器额外提供能力。
第 2 条是 initialize 响应。
服务器回的 id 还是 1。
result 里有 protocolVersion、capabilities 和 serverInfo。
页面里的服务器叫 docs-server,版本 0.4.2。
capabilities.tools.listChanged 是 false,意思是工具列表变了不会主动通知。
这条响应一回来,双方才算握完手。
第 2 条和第 3 条之间,客户端发一条 notifications/initialized。
它没有 id,服务器也不回。
页面把它画成一条窄通知条,只有 54 B。
第 3 条是 tools/list 请求。
id 是 2,没有参数。
第 4 条是响应,result.tools 里正好 3 个工具。
read_file 接收 path。
search_docs 接收 query。
run_query 接收 sql。
每个工具都有 description 和 inputSchema,参数类型是 JSON Schema 的写法。
第 5 条是 tools/call 请求。
id 是 3,params.name 是 read_file。
params.arguments 里放 { "path": "docs/readme.md" }。
第 6 条是响应。
result.content 是数组,第一项 type 为 text。
text 里是文件内容,共 4 行。
isError 是 false。
trace 里能看到 permission_check 先通过,然后才是 file_read。
正常场景的 JSON 总字节是 1458 B。
其中 6 条请求/响应占 1404 B,通知占 54 B。
tools/list 的响应最大,621 B,因为三个工具的 inputSchema 都在这条里。
六、权限门与两种错误
越权场景只改了一个参数。
path 从 docs/readme.md 换成 ../../etc/passwd。
服务器没有直接读文件。
它先把路径规范化,再判断是否还在 /workspace/docs 下面。
不在,就返回 isError: true。
错误码是 -32001,信息是"路径越出允许根目录 /workspace/docs"。
trace 里只有 permission_check,没有 file_read。
这说明拦截发生在读文件之前,不是读完再报错。

未知工具走的是另一条路。
tools/call 里写 convert_video,但 tools/list 根本没声明这个工具。
服务器返回 JSON-RPC error,错误码 -32602,信息是 Unknown tool: convert_video。
这里没有 result.isError,因为请求在协议层就不成立。
页面用橙色"协议错误"和红色"已拦截"区分这两种失败。
这个区分很重要。
工具执行失败,客户端还能拿到工具说了什么。
协议错误,客户端拿到的是"你这次请求本身有问题"。
两者的重试策略也不一样。
前者可以换参数重试,后者要先修请求。

七、验证与踩坑:12 条断言先挂了一条
页面底部有 12 条断言,打开就自动跑。
第一次是 11/12。
红的是第 10 条"所有请求 id 递增"。
原因前面说过,三个场景的 id 被拼在一起。
修复后,每个场景的请求 id 都是 [1,2,3]。
第 7 条也重写了。
原来它判断 error 对象是否存在,现在判断 result.isError === true。
第 8 条跟着改成读 result._meta.errorCode。
第 12 条检查越权场景的 trace 里只有 permission_check。
这条能过,说明权限门确实在读取之前。
几条关键断言的实测返回值如下。
| 断言 | 实测返回值 |
|---|---|
| 正常场景消息数 | 6 |
| 往返次数 | 3 |
| 协商版本 | 2025-06-18 |
| tools/list 工具数 | 3 |
| read_file 返回首行 | # MCP 演示文档 |
| 正常场景 isError | false |
| 越权场景 isError | true |
| 越权错误码 | -32001 |
| 未知工具错误码 | -32602 |
| 请求 id | normal1,2,3 forbidden1,2,3 unknown1,2,3 |
| initialized 通知 | 没有 id 字段 |
| 越权 trace | 只有 permission_check |
最后是 12/12。
失败路径不是靠截图摆拍。
点"越权路径",页面会清空消息并重放。
右侧结果卡变红,显示"已拦截"。
再点"未知工具",结果卡变橙,错误码换成 -32602。
两次失败都能在页面上复现。

八、本地复现
整页只有一个 index.html。
在文件所在目录起一个静态服务:
yaml
python -m http.server 8080
浏览器打开 http://localhost:8080/index.html 就能跑。
不需要 npm,不需要后端,也不会发网络请求。
页面里的消息、权限判定和 trace 都是浏览器内存里的确定性数据。
案例和源码放在公开仓库:gitcode.com/OPCTeam/dem...
打开后默认就是正常读取场景。
五张截图都是 1600×1000,分别是正常总览、正常调用、越权拦截、未知工具和自检 12/12。
九、边界与下一步
这是一个前端模拟器,不是真实的 MCP 服务器。
它不连接真实模型,也不读取真实文件。
路径规范化只做了最基础的前缀判断,没有处理符号链接、Windows 短路径和 URL 编码。
真实服务器还要考虑这些。
页面也没有实现 host 侧的人工确认。
真实产品里,工具调用前通常要让用户看一眼参数。
这个实验室只模拟了服务器侧的权限门。
下一步想加三样东西。
一是 resources/list 和 prompts/list,把 MCP 的三类能力补齐。
二是 SSE 或 stdio 传输,让消息不再只是页面里的数组。
三是把权限规则做成可编辑的白名单,看不同规则放行和拦截的差异。
十、使用码道体会
这次最省事的是页面骨架。
一句话需求,码道把三栏布局、消息卡、结果卡和自检面板都搭起来了。
需要我补的是协议字段和判定逻辑。
第一版断言里那个"三个场景拼 id"的错,就是典型:界面像样,判断不成立。
我的用法是:让码道做界面和数据结构,自己盯住断言、错误语义和失败路径。
需求里把"12 条断言必须读真实状态"写进去,比事后补测试有效。
生成后把源码落到本地,用浏览器把三个场景各点一遍,再决定哪条要改。
十一、准备环境:进入码道 Web
浏览器打开码道 Web 版:devcloud.cn-north-4.huaweicloud.com/chat?source...
登录后就能在对话窗口输入需求,不需要装软件。
本文的页面就是用这条入口生成的。