package mcp /* MCP 自动转发通道的判据(2026-10-04)。 ★ 用户要的:「不只是 Agent 主动回,还有自动转发 Agent 的最后一条消息」。 ## 为什么 MCP 侧需要显式上报,而插件侧不用 插件(pi / dsh / homeagent)有 `session.idle` 钩子: 宿主进程在自己的会话里 idle 时读到最后一条回复 → 自动调 send_mail(relay=…) MCP 宿主(Codex / Claude Desktop)**没有这个概念**: 它不知道「这一轮处理完了」,也不会主动调 send_mail。 ⇒ 宿主必须显式上报,服务端才可能自动转发。 ## 幂等靠什么 `relay_key` 由**宿主**生成(模型伪造不出),服务端 `relayed_mails` 有唯一约束 ⇒ 同一 id 只能转发一次,重试拿 duplicate_relay 而不重复投递。 与各桥的 relay 幂等语义一致(见 handler/mail.go 的注释)。 */ import ( "context" "github.com/agentmail/gateway/internal/db" "strings" "testing" ) func TestSendMailExposesRelayParams(t *testing.T) { s := NewServer(nil) NewTools().RegisterAll(s) props, _ := s.tools["send_mail"].Schema().InputSchema["properties"].(map[string]any) for _, k := range []string{"relay", "relay_key"} { if _, has := props[k]; !has { t.Errorf("★ send_mail 缺参数 %q —— MCP 接入端无法走免配额转发通道,"+ "「自动转发最后一条」在这条路上不成立", k) } } } // relay 非空时必须带 relay_key,否则幂等无从谈起。 func TestRelayRequiresKey(t *testing.T) { setupDB(t) // ★ 收件人必须是真实用户。 // 初版没建 gui-lab,于是请求在「收件人不存在」那一步就 400 了, // 而 parseRelay 的 400 也被算成 200 分支之外 —— 判据红着但原因不对。 // 修正后仍红,才说明「relay 缺 key 没被拒」是真的。 if _, err := mustSeedHuman(t, "gui-lab"); err != nil { t.Fatalf("建收件人: %v", err) } s := newTestServer() // 无 relay_key 就该被服务端拒(400),而不是静默当成普通发信 code, msg := call(t, s, "pi", "send_mail", map[string]any{ "to": "gui-lab@/tmp.new", "subject": "缺 relay_key", "body": "x", "relay": "summary", }) txt, _ := toolText(t, msg) if !strings.Contains(strings.ToLower(txt), "relay_key") && !strings.Contains(strings.ToLower(txt), "relay key") { t.Errorf("★ relay 非空而 relay_key 为空,服务端应明确报错;实际 %d %q", code, txt) } } // relay_key 幂等:同一 key 转发两次,第二次必须是 duplicate 而非新邮件。 func TestRelayKeyIsIdempotent(t *testing.T) { setupDB(t) if _, err := mustSeedHuman(t, "gui-lab"); err != nil { t.Fatalf("建收件人: %v", err) } s := newTestServer() const key = "mcp-relay-idem-1" _, first := call(t, s, "pi", "send_mail", map[string]any{ "to": "gui-lab@/tmp.new", "subject": "转给人类", "body": "本轮总结", "relay": "summary", "relay_key": key, }) txt1, _ := toolText(t, first) if strings.Contains(txt1, "执行失败") { // ★ 不许 Skip。Skip 会让这一格在 CI 上悄悄消失,而幂等是 // 「自动转发」不重投的唯一保证 —— 它绿着才敢让宿主上报。 // (初版这里 Skip 过一轮:.relay-idem 会话不存在 ⇒ // 「幂等没被击穿」的假绿。判据不可达 = 描述的状态不存在。) t.Fatalf("relay 转发不该失败:%s", txt1) } _, second := call(t, s, "pi", "send_mail", map[string]any{ "to": "gui-lab@/tmp.new", "subject": "转给人类", "body": "本轮总结", "relay": "summary", "relay_key": key, }) txt2, _ := toolText(t, second) if !strings.Contains(txt2, "duplicate") && !strings.Contains(txt2, "已转发") { t.Errorf("★ 同一 relay_key 第二次转发应报重复,实际:%s", txt2) } // ★ 只有一封真邮件(否则是重复投递,而那正是要防的)。 // 按**收件人**计数,不按会话别名 —— `.new` 每次会建新会话, // 按别名查会得到 0,那与「幂等被击穿」是两回事,不能混。 var n int if err := dbCountMailsTo(t, "gui-lab", &n); err != nil { t.Fatalf("计数: %v", err) } if n != 1 { t.Errorf("★ 幂等被击穿:同一 relay_key 发给同一收件人产生了 %d 封邮件", n) } _ = context.Background() } // mustSeedHuman 建一个真实人类用户(relay 免配额的条件是收件人是人)。 func mustSeedHuman(t *testing.T, username string) (bool, error) { t.Helper() _, err := db.DB.ExecContext(context.Background(), `INSERT INTO users (username, password_hash, role) VALUES ($1,'x','user') ON CONFLICT (username) DO NOTHING`, username) return true, err } // dbCountMailsTo 数发给某收件人的邮件数。 func dbCountMailsTo(t *testing.T, to string, n *int) error { t.Helper() return db.DB.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM mails WHERE to_name = $1`, to).Scan(n) }