Files
MailUI4Agents/server/internal/mcp/rename_proposal_test.go
JianFeeeee 22107308bb 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 包全绿。
2026-10-04 17:08:15 +08:00

457 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package mcp
/*
改名提议的判据。
# 为什么这一层要单独钉
标记格式是**服务端正则的镜像**(`server/internal/handler/rename_proposal.go` 的
`renameProposalRe`)。格式不一致的后果是**静默**的:
邮件照常发出 → 服务端正则匹配不上 → 提议凭空消失
→ 模型以为自己提过了 → 下一封邮件用那个不存在的别名寻址 → 404
所以这里不只验「能拼出标记」,还验**与 JS 版逐字节相同**、且**能被服务端正则
真的解出来**(直接调用服务端那个正则,不是另写一个)。
*/
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/agentmail/gateway/internal/middleware"
)
// 服务端的正则,在这里 import 而不是复制 —— 复制一份就等于放弃了「镜像」的意义。
// 这里直接用 handler 包里的那个(它是导出的)。
func serverExtract(body string) (alias, reason string, ok bool) {
for _, m := range renameProposalRe.FindAllStringSubmatch(body, -1) {
if len(m) >= 2 {
return m[1], func() string {
if len(m) > 2 {
return m[2]
}
return ""
}(), true
}
}
return "", "", false
}
func TestRenameProposalRoundTripsThroughServerRegex(t *testing.T) {
body, ok := appendRenameProposal("干完了,根因是登录态泄漏。", "fix-login-leak", "定位到泄漏点在 cookie 过期判断")
if !ok {
t.Fatal("合法别名必须被接受")
}
alias, reason, found := serverExtract(body)
if !found {
t.Fatalf("★ 服务端正则必须能解出标记(解不出 = 提议静默消失):body=%q", body)
}
if alias != "fix-login-leak" {
t.Errorf("alias=%q", alias)
}
if reason != "定位到泄漏点在 cookie 过期判断" {
t.Errorf("reason=%q", reason)
}
}
// 与 JS 版逐字节相同 —— 这是「跨语言实现同一份协议」的核心风险。
func TestRenameProposalMatchesJSByteForByte(t *testing.T) {
const NL = "\n\n" // 标记前是两个换行
cases := []struct{ body, alias, reason, want string }{
{"正文", "fix-a", "理由", "正文" + NL + `<!-- agentmail:rename-session alias="fix-a" reason="理由" -->`},
{"正文", "fix-a", "", "正文" + NL + `<!-- agentmail:rename-session alias="fix-a" -->`},
// 理由里的双引号要去掉(HTML 注释里没有转义机制)
{"正文", "fix-a", `他说"好"`, "正文" + NL + `<!-- agentmail:rename-session alias="fix-a" reason="他说好" -->`},
}
for _, c := range cases {
got, ok := appendRenameProposal(c.body, c.alias, c.reason)
if !ok {
t.Fatalf("case %+v 应被接受", c)
}
if got != c.want {
t.Errorf("★ 与 JS 版不一致\n Go: %q\n JS: %q", got, c.want)
}
}
}
// 非法别名:原样返回、不追加标记,并如实告诉模型「没提交」。
func TestRenameProposalRejectsIllegalAlias(t *testing.T) {
for _, alias := range []string{"", " ", "new", "has.dot", "has space", "has/slash", "has@at", `has"quote`} {
body, ok := appendRenameProposal("正文", alias, "理由")
if ok {
t.Errorf("★ 非法别名 %q 被接受了(会发一个服务端匹配得上却被 validateSessionAlias 拒掉的标记)", alias)
}
if body != "正文" {
t.Errorf("别名 %q 非法时正文必须原样返回,实际 %q", alias, body)
}
// 空别名 = 根本没提议 ⇒ 不该有说明(与 JS 版一致)。
// 非空但不合法 ⇒ 必须说明「未提交」,否则模型以为自己提过了。
note := renameProposalNote("", alias, false)
trimmed := strings.TrimSpace(alias)
if trimmed != "" && note == "" {
t.Errorf("★ 别名 %q 不合法时必须给模型一句「未提交」的说明", alias)
}
if trimmed == "" && note != "" {
t.Errorf("别名是空的(没提议)时不该有说明,实际 %q", note)
}
}
}
// 回显必须用**服务端给的**别名:normalizeAlias 改过之后,本地值是不存在的名字。
func TestRenameProposalNoteUsesServerAlias(t *testing.T) {
// 服务端规范化了(new → session-new 之类)
note := renameProposalNote("fixed-login", "fix登录", true)
if !strings.Contains(note, "fixed-login") {
t.Errorf("★ 必须回显服务端返回的别名,实际 %q", note)
}
if !strings.Contains(note, "规范化") {
t.Errorf("本地值与服务端值不同时必须说明被规范化了,实际 %q", note)
}
// 必须说「等用户确认」—— 不说模型会以为已经生效,接着用新别名发信(那个别名还不存在)
if !strings.Contains(note, "确认") {
t.Errorf("必须说明要等人确认,实际 %q", note)
}
}
// 三种情形各有各的话,不能串。
func TestRenameProposalNoteThreeOutcomes(t *testing.T) {
if n := renameProposalNote("", "", false); n != "" {
t.Errorf("没有提议时不该有说明,实际 %q", n)
}
if n := renameProposalNote("", "fix-a", true); !strings.Contains(n, "未被服务端接受") {
t.Errorf("标记发出但服务端没回值 ⇒ 应说未被接受,实际 %q", n)
}
if n := renameProposalNote("fix-a", "fix-a", true); strings.Contains(n, "规范化") {
t.Errorf("两端一致时不该提规范化,实际 %q", n)
}
}
// ★ 与 JS 版的合法判据一致:超长别名拒绝。
func TestRenameProposalRejectsOverlongAlias(t *testing.T) {
long := strings.Repeat("a", 129)
if _, ok := appendRenameProposal("正文", long, ""); ok {
t.Error("★ 超 128 字节的别名必须拒绝(服务端是 VARCHAR(128))")
}
if _, ok := appendRenameProposal("正文", strings.Repeat("a", 128), ""); !ok {
t.Error("正好 128 字节应当接受")
}
}
// ---- 工具层接线 ----
// ★ 这一格钉的是「参数真的接上了」:上一版三个参数都缺(propose_alias、
//
// offset、session_alias),而工具照常工作 —— 缺参数不会报错,只会让模型
// 以为能力不存在。
/*
工具面必须与插件版**双向**对齐(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)
plugin := parsePiBridgeToolParams(t)
// 插件有而 MCP 没有的:必须逐个在豁免名单里有理由
intentional := map[string]map[string]string{
"connect_to_server": {
"gateway_url": "改坐标是部署动作,不该由工具调用触发",
"key_token": "身份只从 AgentAuth 的 context 取,不从参数取",
},
}
for tool, want := range plugin {
props, ok := s.tools[tool]
if !ok {
t.Errorf("★ 插件有工具 %q,MCP 没有", tool)
continue
}
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)
}
}
}
// 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 事件流(投递/拉起对话)────────────────────────
/*
★ 方言必须是 JSON-RPC 通知。
这一格存在的理由:Frame 决定了接入方能不能解析。写错了不会报错 ——
连接是活的、字节在流动,只是客户端**解不开**而默默丢弃每一个事件。
那种失败看起来完全像「没有人给我发信」。
*/
func TestFrameRendersJSONRPCNotification(t *testing.T) {
out := Frame("7", "new_mail", []byte(`{"mail_id":"m1","subject":"你好"}`))
// ★ 三行 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)
}
// payload 必须**原样嵌入**而不是被转义成字符串 ——
// 否则客户端要解析两次,长度还翻倍。
if !strings.Contains(out, `"payload":{"mail_id":"m1","subject":"你好"}`) {
t.Errorf("payload 应原样嵌入(不是 JSON 字符串):%q", out)
}
// 反向对照:转义版本(错误实现)不该出现
if strings.Contains(out, `"payload":"{`) {
t.Errorf("★ payload 被转义成字符串了:%q", out)
}
}
func TestFrameCarriesEventTypeAndID(t *testing.T) {
out := Frame("42", "session_update", []byte(`{}`))
if !strings.Contains(out, `"event_id":"42"`) {
t.Errorf("必须带 event_id(客户端据此做 Last-Event-ID 续传):%q", out)
}
if !strings.Contains(out, `"event_type":"session_update"`) {
t.Errorf("必须带 event_type(否则客户端分不清新邮件与会话更新):%q", out)
}
}
// 单行 + 空行分隔:SSE 的帧完整性靠它,多一个换行会把一条拆成两条。
func TestFrameIsSingleEvent(t *testing.T) {
out := Frame("1", "new_mail", []byte(`{}`))
if strings.Count(out, "\n\n") != 1 {
t.Errorf("一个事件只能有一个空行分隔:%q", out)
}
if strings.Contains(strings.TrimSuffix(out, "\n\n"), "\n\n") {
t.Errorf("事件体内不得出现空行:%q", out)
}
}
// Accept 判定:必须真的要求 text/event-stream。
func TestAcceptsEventStream(t *testing.T) {
cases := []struct {
accept string
want bool
}{
{"text/event-stream", true},
{"text/event-stream, application/json", true},
{"text/event-stream;q=0.9", true},
{"TEXT/EVENT-STREAM", true},
{"application/json", false},
{"", false},
{"text/plain", false},
}
for _, c := range cases {
r := httptest.NewRequest(http.MethodGet, "/api/v1/mcp", nil)
if c.accept != "" {
r.Header.Set("Accept", c.accept)
}
if got := acceptsEventStream(r); got != c.want {
t.Errorf("Accept=%q 期望 %v 收到 %v", c.accept, c.want, got)
}
}
}
// 未认证的 GET 必须拒绝(不能变成一处静默的匿名订阅)。
func TestGetRequiresAgentIdentity(t *testing.T) {
s := newTestServer()
rec := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/api/v1/mcp", nil)
r.Header.Set("Accept", "text/event-stream")
// 故意不注入身份
s.HandleGET(rec, r)
if rec.Code != http.StatusUnauthorized {
t.Errorf("匿名订阅必须 401,收到 %d", rec.Code)
}
}
// 不带 Accept 的 GET 必须 406(接错的客户端会静默收不到东西)。
func TestGetRequiresEventStreamAccept(t *testing.T) {
s := newTestServer()
rec := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/api/v1/mcp", nil)
r = r.WithContext(context.WithValue(r.Context(), middleware.AgentNameKey, "probe"))
s.HandleGET(rec, r)
if rec.Code != http.StatusNotAcceptable {
t.Errorf("缺 Accept 应 406,收到 %d", rec.Code)
}
}