feat(mcp)★★: 自动转发通道 —— relay/relay_key,并修掉一个从未生效的幂等文案
用户 2026-10-04:「不只是 Agent 主动回,还有自动转发 Agent 的最后一条消息」。
## 为什么 MCP 侧需要显式上报,而插件侧不用
插件(pi / dsh / homeagent)有 `session.idle` 钩子:
宿主进程在自己会话 idle 时读到最后一条回复 → 自动 send_mail(relay=…)
MCP 宿主(Codex / Claude Desktop)**没有这个概念**:
不知道「这一轮处理完了」,也不会主动调 send_mail。
⇒ 宿主必须显式上报,服务端才可能自动转发。
所以新增 `send_mail(relay="summary", relay_key="<宿主消息 id>")`:
relay=summary 免配额(服务端既有逻辑),relay_key 提供幂等。
## 顺带修掉一个从未生效的幂等文案(既有缺陷)
MCP 侧读:str(payload, "duplicate_relay")
服务端返:{"status": "duplicate_relay", "detail": "该上游消息已转发过…"}
**字段名对不上 ⇒ 那段逻辑从来没生效过。**
后果对 MCP 接入端尤其致命:它的自动转发正是靠 relay_key 幂等兜底,
而宿主重试时看到的是「已发送」(无 mail_id、无任何异常),
于是继续重试 —— 每一轮都在消耗一次往返。
现在文案是:
该上游消息(relay_key: xxx)已转发过,本次调用未产生新邮件 —— 不需要重试。
## 判据(3 格,2 个变异都转红)
- send_mail 暴露 relay / relay_key(去掉透传 → 转红)
- relay 非空而 relay_key 为空必须被拒
- 同一 relay_key 第二次转发报重复,且**只有一封真邮件**
## 判据自己错了两次
1. `TestRelayRequiresKey` 红着但**原因不对**:没建收件人,400 发生在
「收件人不存在」那一步,掩盖了 parseRelay 的校验。
2. 幂等那格被我自己 `Skip` 了 —— `.relay-idem` 会话不存在,
于是「幂等没被击穿」的假绿。改为 `.new` 并**禁止 Skip**:
幂等是自动转发不重投的唯一保证,它绿着才敢让宿主上报。
(判据不可达 = 描述的状态不存在。)
## 双向对齐判据立刻证明了价值
加了 relay/relay_key 却没登记,它当场红:
★ send_mail 的参数 "relay" 既不在插件版、也没登记为 MCP 额外
若它不存在,加了新参数就会静默通过,而「这个能力是有意加的还是漏登记的」
正是以后没人能回答的问题。relay 登记为「额外」而非「缺失」——
插件侧走 session.idle,不需要这条参数。
14 包全绿。
This commit is contained in:
124
server/internal/mcp/relay_test.go
Normal file
124
server/internal/mcp/relay_test.go
Normal file
@ -0,0 +1,124 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user