MCP 协议 6 条消息拆开看:AI 为什么能自己读文件调接口

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...

登录后就能在对话窗口输入需求,不需要装软件。

本文的页面就是用这条入口生成的。

相关推荐
风骏时光牛马29 分钟前
请告诉我标题对应的内容、想要的风格,我马上给你生成。
前端
deli0071 小时前
OAuth 2.0 授权码流程实测:4 次跳转,少个 state 就被劫持
前端
deli0073 小时前
手机20%突然关机?电量百分比是估的,SOC曲线4组实测
前端
深蓝AI13 小时前
Meta 开源用了八年的设计系统 astryx:连坐两天 GitHub 总榜第一,组件库开始写给 agent 的说明书
前端·人工智能
IT_陈寒14 小时前
Vue的watch把我坑惨了,原来要这样用才靠谱
前端·人工智能·后端
1-1=014 小时前
Vue 3 模板语
前端·javascript·vue.js
用户76103044737114 小时前
【转战智能眼镜开发】ROKID AIUI 小游戏 亚马逊标枪项目实战讲解
前端
风卿15 小时前
看着AI写代码太孤独了,来欣赏星云吧:一个DSH 装饰插件
前端·javascript