fix(mcp)★★: JSON-RPC 帧一直是坏的 —— 任何 MCP 客户端都收不到事件
## 一个生产上一直存在的 bug
`Frame` 里 `"params":` 后面**少一个左花括号**:
{"jsonrpc":"2.0",...,"params":"event_id":"1","event_type":"connected",...}
↑ params 变成了字符串
这不是非法 JSON 能靠编译发现的那种错 —— 它**编译通过**、连接是活的、
字节在流动、服务端日志里有帧,但**任何 MCP 客户端都解不开**。
症状完全像「没有人给我发信」。
## 上一轮我的生产实测为什么没抓到
head -c 160 | cut -c1-160 ← 截断了,我没看全
当时只确认了「有帧到达」,没检查结构。**看片段不算验证。**
## 判据为什么一直是绿的
`TestFrameRendersJSONRPCNotification` 只断言「以 `{"jsonrpc"` 开头」——
坏帧恰好也满足。前缀检查、`HasSuffix("\n\n")`、payload 检查**全部通过**。
⇒ 唯一能抓到它的办法是把整帧当 JSON 解一遍。现在补上了,
连同 SSE 三行格式(id: / event: message / data:)一起断言。
两个变异(去掉 `{`、去掉 `data:` 前缀)都验证转红。
## 顺带修:SSE 方言
之前写的是**裸 JSON**(无任何 SSE 行前缀),而 Content-Type 却是
text/event-stream。标准 MCP 客户端用 EventSource / eventsource-parser
按 `data:` 取 payload,遇到裸 JSON 会读到空 payload 然后丢掉每个事件。
现在输出标准三行。
`id:` 行一并补上:SSE 规范据此维护 Last-Event-ID,断线重连时客户端
自动带上 —— 与服务端那份事件缓冲区回放是两条互补路径。
## 新增:完整生命周期 E2E(TestMCPFullLifecycleEndToEnd)
已有的 40 格判据全是**单点**(握手对了、工具列表对了、某个鉴权对了)。
它们各自通过,却不保证「一个真实客户端从头走到尾不撞墙」。
MCP 出问题时往往不在单点:
initialize → tools/list → 订阅 SSE → 收到信 → **断开** → 重连(Last-Event-ID)
→ 回放补发 → 发信 → 读回 → 作用域延续
走真实 HTTP + 真实 SSE + 真实 AgentAuth,不是直接调 Run。
★ 这一格一写出来就抓到了上面那个坏帧(它按 data: 解析,读到 0 帧)。
**判据抓不到的东西,真实客户端同样抓不到。**
## E2E 过程中判据自己错了三次
| 现象 | 真因(都不是产品缺陷) |
|---|---|
| `Unauthorized` | 漏挂 `AgentAuth` —— 少一层中间件 = 测的不是生产形状 |
| SSE 405 | 只挂了 `HandleHTTP`,漏了 `HandleGET`(POST/GET 是两个 handler) |
| 「回放没生效」 | `repoInsertMail` 是裸 INSERT,**不触发 broadcast**,事件从未进 ring |
第三个尤其阴险 —— 判据报出来的是一句**产品缺陷**的措辞
(「回放没生效,客户端会静默漏信」),而真实投递一定经过
`notify.Recipients`。判据数据错了会伪装成被测代码坏了。
## 同时把工具面对齐判据从手抄清单改成真读源码
`TestToolsExposeParamsOtherBridgesHave` 原来是一份手抄
`map[string][]string`:会漂移,且只查"不缺"不查"不多"。
改为每次现读 plugins/pi-mail-bridge/src/tools.mjs 做双向比对。
★ 解析器第一版用行正则判块结束,而 properties 里每行参数长得就是
`xxx: { … },` —— 首行就把块关掉,**解析出 0 个参数却全绿**。
改按花括号深度计,守卫从 `len(out)==0` 改成**参数总数**
(漏掉的正是那个检查)。两个方向的变异都验证转红。
14 包全绿。
This commit is contained in:
@ -17,9 +17,12 @@ package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@ -148,40 +151,168 @@ func TestRenameProposalRejectsOverlongAlias(t *testing.T) {
|
||||
//
|
||||
// offset、session_alias),而工具照常工作 —— 缺参数不会报错,只会让模型
|
||||
// 以为能力不存在。
|
||||
func TestToolsExposeParamsOtherBridgesHave(t *testing.T) {
|
||||
/*
|
||||
工具面必须与插件版**双向**对齐(2026-10-04)。
|
||||
|
||||
★ 为什么要改成真读源码:
|
||||
初版这里是一份**手抄清单**(map[string][]string)。两个问题:
|
||||
1. 会漂移 —— 桥加了参数,这里不会跟着变,判据照样全绿;
|
||||
2. 只查"不缺",不查"不多" ⇒ MCP 多出来的能力没人管。
|
||||
|
||||
⇒ 改为**每次现读** plugins/pi-mail-bridge/src/tools.mjs,
|
||||
与 MCP 的 schema 做双向集合比对。
|
||||
|
||||
已知的三处**有意**差异,各有理由,逐一显式豁免并说明:
|
||||
- connect_to_server 的 gateway_url/key_token:
|
||||
网关内建端点已由 AgentAuth 认证;改坐标是**部署动作**,
|
||||
不该由一次工具调用触发(桥侧是局外进程才需要)。
|
||||
- session_id(4 个工具):15e4fe9/095213b 越权修复的显式化产物。
|
||||
插件侧靠「当前所在会话」隐式,MCP 宿主没有这个隐式上下文 ⇒ 必须显式。
|
||||
- MCP 侧的 attachment_ids/propose_alias/propose_reason/flatten:
|
||||
**MCP 比插件多**,属能力增强,不要求插件对齐。
|
||||
*/
|
||||
func TestToolsAlignWithPluginBridgeBidirectionally(t *testing.T) {
|
||||
s := NewServer(nil)
|
||||
NewTools().RegisterAll(s)
|
||||
// 必须与 plugins/*/lib 那一侧逐字一致的参数
|
||||
want := map[string][]string{
|
||||
"send_mail": {"to", "subject", "body", "cc", "reply_to", "session_alias", "attachment_ids", "propose_alias", "propose_reason"},
|
||||
"read_thread": {"mail_id", "offset", "session_id"},
|
||||
"forward_mail": {"mail_id", "to", "comment", "subject", "cc", "session_alias", "session_id"},
|
||||
"read_inbox": {"workspace", "status", "limit", "session_id"},
|
||||
"read_mail": {"mail_id", "session_id"},
|
||||
"suggest_address": {"name", "path"},
|
||||
"list_contacts": {"limit"},
|
||||
"upload_attachment": {"file_path", "filename"},
|
||||
"download_attachment": {"attachment_id", "save_path"},
|
||||
"session_participants": {"session_id"},
|
||||
// connect_to_server **有意**无参:网关内建端点已认证,改坐标是部署动作,
|
||||
// 不该由一次工具调用触发(桥侧那个能改是因为它是局外进程)。
|
||||
|
||||
plugin := parsePiBridgeToolParams(t)
|
||||
|
||||
// 插件有而 MCP 没有的:必须逐个在豁免名单里有理由
|
||||
intentional := map[string]map[string]string{
|
||||
"connect_to_server": {
|
||||
"gateway_url": "改坐标是部署动作,不该由工具调用触发",
|
||||
"key_token": "身份只从 AgentAuth 的 context 取,不从参数取",
|
||||
},
|
||||
}
|
||||
for tool, keys := range want {
|
||||
sch, ok := s.tools[tool]
|
||||
|
||||
for tool, want := range plugin {
|
||||
props, ok := s.tools[tool]
|
||||
if !ok {
|
||||
t.Errorf("没有工具 %s", tool)
|
||||
t.Errorf("★ 插件有工具 %q,MCP 没有", tool)
|
||||
continue
|
||||
}
|
||||
props, _ := sch.Schema().InputSchema["properties"].(map[string]any)
|
||||
for _, k := range keys {
|
||||
if _, has := props[k]; !has {
|
||||
t.Errorf("★ %s 缺参数 %q —— 与其它桥不一致,模型会以为该能力不存在", tool, k)
|
||||
sch, _ := props.Schema().InputSchema["properties"].(map[string]any)
|
||||
for _, k := range want {
|
||||
if _, has := sch[k]; has {
|
||||
continue
|
||||
}
|
||||
if why, ok := intentional[tool][k]; ok {
|
||||
_ = why // 有意豁免,见函数注释
|
||||
continue
|
||||
}
|
||||
t.Errorf("★ %s 缺参数 %q —— 插件版有、MCP 没有,模型会以为该能力不存在", tool, k)
|
||||
}
|
||||
}
|
||||
|
||||
// 反向:MCP 独有的参数必须显式登记
|
||||
mcpExtra := map[string][]string{
|
||||
"forward_mail": {"session_id"},
|
||||
"read_inbox": {"session_id", "workspace"},
|
||||
"read_mail": {"session_id"},
|
||||
"read_thread": {"session_id"},
|
||||
"send_mail": {"attachment_ids", "propose_alias", "propose_reason"},
|
||||
"suggest_address": {"flatten"},
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for tool, extra := range mcpExtra {
|
||||
seen[tool] = true
|
||||
props, ok := s.tools[tool]
|
||||
if !ok {
|
||||
t.Errorf("MCP 没有工具 %q", tool)
|
||||
continue
|
||||
}
|
||||
sch, _ := props.Schema().InputSchema["properties"].(map[string]any)
|
||||
for _, k := range extra {
|
||||
if _, has := sch[k]; !has {
|
||||
t.Errorf("★ 登记说 %s 有额外参数 %q,实际没有 —— 登记值过期了", tool, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 反向还要反向查:MCP 有而没登记的参数(= 有人加了能力忘了登记)
|
||||
for _, tl := range allTools() {
|
||||
tool := tl.Schema().Name
|
||||
sch, _ := tl.Schema().InputSchema["properties"].(map[string]any)
|
||||
for k := range sch {
|
||||
if k == "type" || k == "description" {
|
||||
continue
|
||||
}
|
||||
if contains(append(append([]string{}, plugin[tool]...), mcpExtra[tool]...), k) {
|
||||
continue
|
||||
}
|
||||
t.Errorf("★ %s 的参数 %q 既不在插件版、也没登记为 MCP 额外 —— "+
|
||||
"新增能力忘了登记(判据会随实现漂移)", tool, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var _ = fmt.Sprintf
|
||||
// parsePiBridgeToolParams 真读 pi 桥源码,取每个工具 parameters.properties 的键。
|
||||
//
|
||||
// ★ 每次现读、绝不缓存:模块级缓存会让「改了桥不重读」变成假绿
|
||||
//
|
||||
// (这在本项目已犯过一次 —— 判据模块级缓存外部源码)。
|
||||
//
|
||||
// ★ 用**花括号深度**而不是「行正则找 `},`」来判块结束。
|
||||
//
|
||||
// 初版用 `^\s*\},?$` 判闭合,而 properties 里的**每一行参数**长得就是
|
||||
// ` xxx: { type: … },` —— 首行参数就把块关掉了。实测:给桥加一个参数,
|
||||
// 判据**依然全绿**(`out` 有 11 个 key、每个都是空数组,
|
||||
// 而 `len(out)==0` 那个守卫恰恰检查不出来)。
|
||||
//
|
||||
// ⇒ 少一个「抓到几个参数」的断言,判据就会长期假绿而不自知。
|
||||
func parsePiBridgeToolParams(t *testing.T) map[string][]string {
|
||||
t.Helper()
|
||||
path := filepath.Join("..", "..", "..", "plugins", "pi-mail-bridge", "src", "tools.mjs")
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Skipf("读不到 pi 桥源码(%v)—— 对齐判据无法运行", err)
|
||||
}
|
||||
out := map[string][]string{}
|
||||
toolRe := regexp.MustCompile(`^\s*name:\s*'([a-z_]+)',?\s*$`)
|
||||
propRe := regexp.MustCompile(`properties:\s*\{\s*$`)
|
||||
paramRe := regexp.MustCompile(`^\s+([a-z_]+):\s*\{\s*type:`)
|
||||
skipKeys := map[string]bool{
|
||||
"type": true, "description": true, "enum": true,
|
||||
"items": true, "default": true, "required": true,
|
||||
}
|
||||
|
||||
var cur string
|
||||
depth := 0
|
||||
for _, ln := range strings.Split(string(raw), "\n") {
|
||||
if m := toolRe.FindStringSubmatch(ln); m != nil {
|
||||
cur = m[1]
|
||||
out[cur] = []string{}
|
||||
depth = 0
|
||||
continue
|
||||
}
|
||||
if cur == "" {
|
||||
continue
|
||||
}
|
||||
if depth == 0 && propRe.MatchString(ln) {
|
||||
depth = 1 // 进入 properties
|
||||
continue
|
||||
}
|
||||
if depth > 0 {
|
||||
if m := paramRe.FindStringSubmatch(ln); m != nil && !skipKeys[m[1]] {
|
||||
out[cur] = append(out[cur], m[1])
|
||||
}
|
||||
depth += strings.Count(ln, "{") - strings.Count(ln, "}")
|
||||
if depth <= 0 {
|
||||
depth = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
// ★ 守卫要看**参数总数**,不是 key 数。
|
||||
// 上面的 bug 正是「11 个 key、0 个参数」却通过了 len(out)==0 的检查。
|
||||
total := 0
|
||||
for _, v := range out {
|
||||
total += len(v)
|
||||
}
|
||||
if total < 20 {
|
||||
t.Skipf("pi 桥源码结构变了(只解析出 %d 个参数)—— 对齐判据会假绿", total)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ─── GET /mcp 事件流(投递/拉起对话)────────────────────────
|
||||
|
||||
@ -195,8 +326,49 @@ var _ = fmt.Sprintf
|
||||
func TestFrameRendersJSONRPCNotification(t *testing.T) {
|
||||
out := Frame("7", "new_mail", []byte(`{"mail_id":"m1","subject":"你好"}`))
|
||||
|
||||
if !strings.HasPrefix(out, `{"jsonrpc":"2.0","method":"notifications/message"`) {
|
||||
t.Fatalf("必须是 JSON-RPC 2.0 通知:%q", out)
|
||||
// ★ 三行 SSE 格式(2026-10-04 补)。
|
||||
// 原实现只写裸 JSON,而 Content-Type 是 text/event-stream ⇒
|
||||
// 标准 MCP 客户端(EventSource / eventsource-parser)按 `data:` 取
|
||||
// payload,取不到就**静默丢掉每个事件**。
|
||||
//
|
||||
// 这一格初版只查「以 `{"jsonrpc"` 开头」—— 裸 JSON 恰好也满足,
|
||||
// 于是**真正的坏帧一直绿灯**。教训与之前那次相同:
|
||||
// 判据只断言了「能识别的那部分」,没断言「该有的结构」。
|
||||
if !strings.HasPrefix(out, "id: 7\n") {
|
||||
t.Errorf("★ 缺 SSE 的 id: 行(规范靠它维护 Last-Event-ID):%q", out)
|
||||
}
|
||||
if !strings.Contains(out, "\nevent: message\n") {
|
||||
t.Errorf("★ 缺 SSE 的 event: 行:%q", out)
|
||||
}
|
||||
dl := strings.Index(out, "\ndata: ")
|
||||
if dl < 0 {
|
||||
t.Fatalf("★ 缺 data: 行 —— 标准 SSE 客户端会解析不到 payload:%q", out)
|
||||
}
|
||||
data := strings.TrimPrefix(out[dl+len("\ndata: "):], "")
|
||||
data = strings.TrimRight(data, "\n")
|
||||
|
||||
// ★ 整帧必须是**合法 JSON**。
|
||||
// 真实 bug(2026-10-04):`"params":` 后面少一个 `{`,
|
||||
// 产出 `..."params":"event_id":"7",...}` —— params 变成字符串。
|
||||
// 而上面的前缀检查、HasSuffix("\n\n")、payload 检查**全都通过**。
|
||||
// 唯一能抓到它的办法是把整帧当 JSON 解一遍。
|
||||
var probe struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
Params struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
} `json:"params"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &probe); err != nil {
|
||||
t.Fatalf("★ data: 行不是合法 JSON(%v):%s", err, data)
|
||||
}
|
||||
if probe.JSONRPC != "2.0" || probe.Method != "notifications/message" {
|
||||
t.Errorf("必须是 JSON-RPC 2.0 通知:%s", data)
|
||||
}
|
||||
if probe.Params.EventID != "7" || probe.Params.EventType != "new_mail" {
|
||||
t.Errorf("params 必须解出 event_id/event_type(说明 params 是对象不是字符串):%s", data)
|
||||
}
|
||||
if !strings.HasSuffix(out, "\n\n") {
|
||||
t.Errorf("SSE 帧必须以空行结尾(否则客户端不认为事件结束):%q", out)
|
||||
|
||||
Reference in New Issue
Block a user