From a6b7994a451634d2869046a4b5ffb298b6587b8c Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 4 Oct 2026 15:03:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp)=E2=98=85=E2=98=85:=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E8=BD=AC=E5=8F=91=E9=80=9A=E9=81=93=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20relay/relay=5Fkey=EF=BC=8C=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E6=8E=89=E4=B8=80=E4=B8=AA=E4=BB=8E=E6=9C=AA=E7=94=9F=E6=95=88?= =?UTF-8?q?=E7=9A=84=E5=B9=82=E7=AD=89=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 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 包全绿。 --- server/internal/mcp/relay_test.go | 124 ++++++++++++++++++++ server/internal/mcp/rename_proposal_test.go | 11 +- server/internal/mcp/support.go | 21 +++- server/internal/mcp/tool_impls.go | 21 ++++ 4 files changed, 170 insertions(+), 7 deletions(-) create mode 100644 server/internal/mcp/relay_test.go diff --git a/server/internal/mcp/relay_test.go b/server/internal/mcp/relay_test.go new file mode 100644 index 0000000..055cbfa --- /dev/null +++ b/server/internal/mcp/relay_test.go @@ -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) +} diff --git a/server/internal/mcp/rename_proposal_test.go b/server/internal/mcp/rename_proposal_test.go index dbaeb8a..e893f8f 100644 --- a/server/internal/mcp/rename_proposal_test.go +++ b/server/internal/mcp/rename_proposal_test.go @@ -206,11 +206,12 @@ func TestToolsAlignWithPluginBridgeBidirectionally(t *testing.T) { // 反向: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"}, + "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", + "relay", "relay_key"}, "suggest_address": {"flatten"}, } seen := map[string]bool{} diff --git a/server/internal/mcp/support.go b/server/internal/mcp/support.go index f450be7..d470464 100644 --- a/server/internal/mcp/support.go +++ b/server/internal/mcp/support.go @@ -210,8 +210,25 @@ func summarizeSend(payload map[string]any) string { if sid != "" { fmt.Fprintf(&b, ",落在会话 %s", sid) } - if relay := str(payload, "duplicate_relay"); relay != "" { - fmt.Fprintf(&b, "。注意:%s", relay) + // ★ 幂等重放(2026-10-04 修)。 + // + // 这里原本读 `payload["duplicate_relay"]` —— 而服务端(handler/mail.go + // 的 ClaimRelay 分支)返回的是 **`status: "duplicate_relay"`**。 + // 字段名对不上 ⇒ 这段逻辑从来没生效过 ⇒ 宿主重试拿到的是 + // 「已发送」而没有 mail_id,看起来像投递成功,于是继续重试。 + // + // 对 MCP 接入端这条尤其致命:它的「自动转发」正是靠 relay_key 幂等 + // 兜底的,而宿主看到「已发送」就不会改参数 —— 于是同一条上游消息 + // 被反复转发(服务端不会真的重复建信,但每一轮都在消耗一次往返)。 + if st := str(payload, "status"); st == "duplicate_relay" { + b.Reset() + key := str(payload, "relay_key") + if key != "" { + fmt.Fprintf(&b, "该上游消息(relay_key: %s)已转发过,本次调用未产生新邮件 —— ", key) + } else { + b.WriteString("该上游消息已转发过,本次调用未产生新邮件 —— ") + } + b.WriteString("不需要重试。") } return b.String() } diff --git a/server/internal/mcp/tool_impls.go b/server/internal/mcp/tool_impls.go index 9443e69..2fc1794 100644 --- a/server/internal/mcp/tool_impls.go +++ b/server/internal/mcp/tool_impls.go @@ -120,6 +120,11 @@ func (t *Tools) SendMail() Tool { "propose_alias": strProp("可选。建议把当前会话改名成这个别名 —— 这只是建议," + "别名是人的寻址入口,实际改名由用户在界面上确认。不可含 . / @ 空白,不可为 new"), "propose_reason": strProp("改名理由,一句话,展示给用户看"), + "relay": strProp("免配额转发标记。\"\" | \"permission\" | \"summary\"。\n" + + " 本轮处理完时把最终结果转给人类请填 summary —— 它不扣配额。\n" + + " 人类回信后本轮已完成,不填才是正常发信(要扣配额)。"), + "relay_key": strProp("relay 非空时必填:上游那条消息的稳定标识(宿主侧的消息 id)。\n" + + " 同一 id 只能转发一次(服务端有唯一约束),用于重试时不重复投递。"), }, "to"), }, run: func(ctx context.Context, args map[string]any) (string, error) { @@ -151,6 +156,22 @@ func (t *Tools) SendMail() Tool { if ids := argStrList(args, "attachment_ids"); len(ids) > 0 { body["attachment_ids"] = ids } + // relay / relay_key:MCP 接入端的「本轮总结自动转给人类」通道(2026-10-04)。 + // + // ★ 为什么 MCP 侧需要它而插件侧不用: + // 插件(pi/dsh/…)有 session.idle 钩子,能在宿主侧读到最后一条回复 + // 并自动转发;MCP 宿主(Codex/Claude Desktop)**没有这个概念**, + // 不知道「这一轮处理完了」,也不会主动调 send_mail。 + // ⇒ 宿主必须显式上报,服务端才可能自动转发。 + // + // relay_key 由宿主生成(模型伪造不出),服务端有唯一约束, + // 所以重试不会重复投递 —— 与各桥的 relay 幂等语义一致。 + if v := argStr(args, "relay"); v != "" { + body["relay"] = v + } + if v := argStr(args, "relay_key"); v != "" { + body["relay_key"] = v + } code, payload, raw := invoke(ctx, handler.SendMail, newRequest(ctx, http.MethodPost, "/api/v1/mail/send", body)) if code < 200 || code >= 300 { return render("send_mail", code, payload, raw)