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

488 lines
16 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
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"github.com/agentmail/gateway/internal/handler"
)
// httptestRecorder 造一个响应收集器。
func httptestRecorder() *httptest.ResponseRecorder { return httptest.NewRecorder() }
// jsonUnmarshal 是 json.Unmarshal 的薄封装(工具文件里用起来更短)。
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
// newMultipartRequest 造一个 multipart/form-data 请求。
func newMultipartRequest(ctx context.Context, body []byte, contentType string) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/api/v1/attachments", bytes.NewReader(body))
r.Header.Set("Content-Type", contentType)
return r.WithContext(ctx)
}
// buildUploadForm 组装上传用的 multipart 表单。
//
// 只**读**文件(打开 + 拷贝),不写。它读的是调用方给出的路径 ——
// 见 tool_impls.go 里 upload_attachment 的注释:这是一处「工具参数即
// 文件系统访问」,只读,且只对本服务自己跑在本机这件事成立(远程
// Streamable HTTP 部署时读的是**网关机器**的路径,不是客户端的)。
func buildUploadForm(path, filename string) ([]byte, string, error) {
if strings.TrimSpace(path) == "" {
return nil, "", fmt.Errorf("缺少 file_path")
}
f, err := os.Open(path)
if err != nil {
return nil, "", fmt.Errorf("打不开 %s:%v", path, err)
}
defer f.Close()
if filename == "" {
filename = filepath.Base(path)
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
// 服务端字段名是 "file"(FormFile("file"))—— 写错名字会得到
// "表单里没有 file 字段" 这种看不懂的错。
part, err := mw.CreateFormFile("file", filename)
if err != nil {
return nil, "", err
}
if _, err := io.Copy(part, f); err != nil {
return nil, "", err
}
if err := mw.Close(); err != nil {
return nil, "", err
}
return buf.Bytes(), mw.FormDataContentType(), nil
}
// summarize 把 handler 的成功响应转成模型读的文本。
//
// 为什么不直接把 JSON 甩给模型:
// - read_inbox 一次可能返回几十封,每封都带邮件正文缩略 —— 纯 JSON 里
// 转义与字段名会占掉大量 token,而且模型容易看错行(哪个是 subject、
// 哪个是 from)。
// - 人类与模型都需要**能扫读**的形状。
//
// 所以这里按已知的响应形状做小渲染;认不出来的形状回退到原始 JSON
// (宁可费 token,也不要把信息丢了 —— 那会让模型以为邮件是空的)。
func summarize(name string, payload map[string]any) (string, error) {
switch name {
case "read_inbox":
return summarizeInbox(payload), nil
case "read_mail":
return summarizeMail(payload), nil
case "read_thread":
return summarizeThread(payload), nil
case "send_mail":
return summarizeSend(payload), nil
case "forward_mail":
return summarizeSend(payload), nil
case "upload_attachment":
return summarizeUpload(payload), nil
case "suggest_address":
return summarizeSuggest(payload), nil
case "list_contacts":
return summarizeContacts(payload), nil
case "session_participants":
return summarizeParticipants(payload), nil
}
return prettyJSON(payload), nil
}
func prettyJSON(payload map[string]any) string {
b, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return fmt.Sprintf("%v", payload)
}
return string(b)
}
func str(m map[string]any, k string) string {
if v, ok := m[k].(string); ok {
return v
}
return ""
}
// summarizeInbox 渲染收件箱:每封一行,带 id(下一步要用)与是否有附件。
func summarizeInbox(payload map[string]any) string {
mails, _ := payload["mails"].([]any)
total := numberOf(payload["total"])
if len(mails) == 0 {
if total > 0 {
return fmt.Sprintf("(本会话/工作区内没有符合条件的未读,但 total=%d)", total)
}
return "收件箱为空。"
}
var b strings.Builder
fmt.Fprintf(&b, "%d 封未读(total=%d):\n\n", len(mails), total)
for _, it := range mails {
m, ok := it.(map[string]any)
if !ok {
continue
}
from := firstNonEmpty(str(m, "from_name"), str(m, "from"), "(未知)")
subj := firstNonEmpty(str(m, "subject"), "(无主题)")
id := str(m, "mail_id")
flag := ""
if n, _ := m["attachments"].([]any); len(n) > 0 {
flag = fmt.Sprintf(" [附件 %d]", len(n))
}
if ws := str(m, "session_workspace"); ws != "" {
flag += " " + ws
}
fmt.Fprintf(&b, "· %s — %s (id: %s)%s\n", from, subj, id, flag)
}
b.WriteString("\n用 read_mail(mail_id=…) 读正文。")
return b.String()
}
// summarizeMail 渲染单封:头部 + 正文 + 附件清单。
func summarizeMail(payload map[string]any) string {
m := payload
if inner, ok := payload["mail"].(map[string]any); ok {
m = inner
}
var b strings.Builder
fmt.Fprintf(&b, "发件人:%s\n主题:%s\n邮件 ID:%s\n日期:%s\n",
firstNonEmpty(str(m, "from_name"), "(未知)"),
firstNonEmpty(str(m, "subject"), "(无主题)"),
str(m, "mail_id"),
firstNonEmpty(str(m, "created_at"), str(m, "sent_at")))
if parent := str(m, "parent_mail_id"); parent != "" {
fmt.Fprintf(&b, "(这是对 %s 的回复)\n", parent)
}
b.WriteString("\n---\n")
b.WriteString(str(m, "body"))
if atts, ok := m["attachments"].([]any); ok && len(atts) > 0 {
b.WriteString("\n\n附件:")
for _, a := range atts {
am, _ := a.(map[string]any)
fmt.Fprintf(&b, "\n· %s (attachment_id: %s, %s)",
firstNonEmpty(str(am, "filename"), "(未命名)"),
str(am, "attachment_id"),
humanSize(numberOf(am["size_bytes"])))
}
}
return b.String()
}
// summarizeThread 渲染线索:按时间顺序每封一段。
func summarizeThread(payload map[string]any) string {
mails, _ := payload["mails"].([]any)
if mails == nil {
mails, _ = payload["thread"].([]any)
}
if len(mails) == 0 {
return "线索里没有邮件。"
}
var b strings.Builder
fmt.Fprintf(&b, "线索共 %d 封:\n\n", len(mails))
for _, it := range mails {
m, _ := it.(map[string]any)
fmt.Fprintf(&b, "── %s · %s(id: %s)\n%s\n\n",
firstNonEmpty(str(m, "from_name"), "(未知)"),
firstNonEmpty(str(m, "subject"), "(无主题)"),
str(m, "mail_id"),
strings.TrimSpace(str(m, "body")))
}
return b.String()
}
func summarizeSend(payload map[string]any) string {
id := str(payload, "mail_id")
sid := str(payload, "session_id")
var b strings.Builder
b.WriteString("已发送")
if id != "" {
fmt.Fprintf(&b, "(mail_id: %s)", id)
}
if sid != "" {
fmt.Fprintf(&b, ",落在会话 %s", sid)
}
// ★ 幂等重放(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()
}
func summarizeUpload(payload map[string]any) string {
// UploadAttachment 返回的是 {attachment:{...}} 这种**嵌套**形状
// —— 按顶层解会得到空 id(homeagent 与 zcode 都踩过)。
att, _ := payload["attachment"].(map[string]any)
if att == nil {
att = payload
}
return fmt.Sprintf("已上传 %s,attachment_id: %s(%s)。在 send_mail 的 attachment_ids 里带上它才会随邮件发出。",
firstNonEmpty(str(att, "filename"), "(未命名)"),
str(att, "attachment_id"),
humanSize(numberOf(att["size_bytes"])))
}
func summarizeSuggest(payload map[string]any) string {
kind := str(payload, "kind")
// flatten 出来的形状(2026-10-02 寻址报告 A):addresses + candidates。
// 必须单独渲染 —— 它没有 suggestions 字段,走下面的分支只会回一句
// 「(没有 session_flat 建议)」,模型拿不到任何地址,等于白问。
if kind == "session_flat" {
cands, _ := payload["candidates"].([]any)
if len(cands) == 0 {
return "没有可列出的会话(对方可能从未与你通信过)。" +
"若要开新线索,用 send_mail 指定 **.new** 结尾的地址 —— " +
"但请先确认工作区:root 与 /root 是两个不同工作区。"
}
var b strings.Builder
fmt.Fprintf(&b, "%d 条可投递地址(每条自带工作区,直接放进 send_mail 的 to):\n\n", len(cands))
for _, it := range cands {
m, _ := it.(map[string]any)
flag := ""
if numberOf(m["unread"]) > 0 {
flag = fmt.Sprintf(" ★%d 未读", int(numberOf(m["unread"])))
}
// ★ 逐条把「这个工作区不该选」标出来(实测 222 条候选里 37 条
// 落在桥内部目录)。标注只在 paths[] 里的话,模型得自己把两个
// 数组对照才认得出 —— 那就是 §C 噪声换个位置复活。
warn := ""
if k := str(m, "path_kind"); k == "bridge-internal" {
warn = " ⚠ 桥内部目录,不是项目工作区"
} else if note := str(m, "path_note"); note != "" {
warn = " ⚠ " + note
}
fmt.Fprintf(&b, "· %s\n 工作区:%s%s\n 标题:%s%s\n",
str(m, "address"), str(m, "path"), warn,
firstNonEmpty(str(m, "title"), "(无标题)"), flag)
}
if paths, ok := payload["paths"].([]any); ok && len(paths) > 0 {
if notes := pathWarnings(paths); notes != "" {
b.WriteString("\n⚠ 工作区候选里的坑(别选它们):\n" + notes)
}
}
return b.String()
}
// path 形状:把「这不是工作区」与「这是相对路径」标出来。
if kind == "path" {
if paths, ok := payload["paths"].([]any); ok && len(paths) > 0 {
var b strings.Builder
b.WriteString("可选工作目录:\n")
for _, it := range paths {
m, _ := it.(map[string]any)
mark := ""
if str(m, "kind") == "bridge-internal" {
mark = " ⚠ 桥内部目录,不是项目工作区"
}
if note := str(m, "note"); note != "" && mark == "" {
mark = " ⚠ " + note
}
fmt.Fprintf(&b, "· %s%s\n", str(m, "path"), mark)
}
if notes := pathWarnings(paths); notes != "" {
b.WriteString("\n" + notes)
}
b.WriteString("\n不知道该用哪个 → 加 flatten=true 一次拿到该收件人的全部可投递地址。")
return b.String()
}
}
suggestions, _ := payload["suggestions"].([]any)
if len(suggestions) == 0 {
return fmt.Sprintf("(没有%s建议)", kind)
}
items := make([]string, 0, len(suggestions))
for _, s := range suggestions {
if str, ok := s.(string); ok {
items = append(items, str)
}
}
if len(items) == 0 {
return fmt.Sprintf("(没有%s建议)", kind)
}
return fmt.Sprintf("可选的%s:\n%s", kind, strings.Join(items, "\n"))
}
func summarizeContacts(payload map[string]any) string {
contacts, _ := payload["contacts"].([]any)
if len(contacts) == 0 {
return "没有参与过任何会话。用 suggest_address 查可投递地址。"
}
var b strings.Builder
fmt.Fprintf(&b, "共 %d 条会话:\n\n", len(contacts))
for _, it := range contacts {
c, _ := it.(map[string]any)
unread := numberOf(c["unread_count"])
flag := ""
if unread > 0 {
flag = fmt.Sprintf(" ★ %d 未读", unread)
}
fmt.Fprintf(&b, "· %s %s%s\n address: %s\n session_id: %s\n",
firstNonEmpty(str(c, "peer"), "(未知)"),
firstNonEmpty(str(c, "subject"), "(无主题)"),
flag,
firstNonEmpty(str(c, "address"), "(无可寻址地址)"),
str(c, "session_id"))
}
return b.String()
}
func summarizeParticipants(payload map[string]any) string {
parts, _ := payload["participants"].([]any)
if len(parts) == 0 {
return "会话里没有其他参与方。"
}
var b strings.Builder
b.WriteString("参与方:\n")
for _, it := range parts {
p, _ := it.(map[string]any)
flag := ""
if !boolOf(p["replied"]) {
flag = "(还没回应)"
}
fmt.Fprintf(&b, "· %s %s%s\n address: %s\n",
firstNonEmpty(str(p, "role"), "?"),
firstNonEmpty(str(p, "name"), "(未知)"),
flag,
firstNonEmpty(str(p, "address"), "(无)"))
}
return b.String()
}
// ---- 小工具 ----
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
func numberOf(v any) int {
switch t := v.(type) {
case float64:
return int(t)
case int:
return t
case json.Number:
n, _ := t.Int64()
return int(n)
}
return 0
}
func boolOf(v any) bool {
b, _ := v.(bool)
return b
}
func humanSize(n int) string {
switch {
case n <= 0:
return "0 B"
case n < 1024:
return fmt.Sprintf("%d B", n)
case n < 1024*1024:
return fmt.Sprintf("%.1f KB", float64(n)/1024)
default:
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
}
}
// agentNameOfRequest 供 handler 包在需要时取身份。
//
// 单独一个 invoke:DownloadAttachment 的响应体是**附件字节**而不是 JSON,
// 走通用的 invoke 会把二进制当 JSON 解析(静默得到 nil),
// 于是成功时也会报"解析不出 error"。所以这里自己收集。
func invokeDownload(ctx context.Context, target, id, savePath string) (int, map[string]any, string) {
// 路径参数 id 必须注入(DownloadAttachment 用 pathUUID → chi.URLParam)。
// 注入后**不要再** WithContext(ctx) —— 那会覆盖掉 RouteContext。
r := withRouteParams(newRequest(ctx, http.MethodGet, target, nil), map[string]string{"id": id})
rec := httptest.NewRecorder()
handler.DownloadAttachment(rec, r)
body := rec.Body.Bytes()
if rec.Code < 200 || rec.Code >= 300 {
var out map[string]any
_ = json.Unmarshal(body, &out)
return rec.Code, out, string(body)
}
// 落盘(建父目录)。写失败要**报错**而不是假装成功 ——
// 模型会以为文件在那儿,然后 upload_attachment 打不开它。
if dir := filepath.Dir(savePath); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return http.StatusInternalServerError,
map[string]any{"error": "建目录失败:" + err.Error()}, ""
}
}
if err := os.WriteFile(savePath, body, 0o644); err != nil {
return http.StatusInternalServerError,
map[string]any{"error": "写文件失败:" + err.Error()}, ""
}
return http.StatusOK, map[string]any{
"saved_path": savePath,
"size_bytes": len(body),
"description": fmt.Sprintf("已保存 %s(%s)", savePath, humanSize(len(body))),
}, ""
}
// pathWarnings 汇总「path 候选里哪些不该选」。
//
// 为什么值得单独做(2026-10-02 寻址报告 B/C 实测):64 条候选里 33 条是
// 桥的内部会话目录,且 root 与 /root 外观只差一个斜杠却是两个不同工作区。
// 混在列表里给模型挑,选中即**静默**投进错误线索(投递返回 200)。
func pathWarnings(paths []any) string {
var internal, relative []string
for _, it := range paths {
m, _ := it.(map[string]any)
if str(m, "kind") == "bridge-internal" {
internal = append(internal, str(m, "path"))
} else if ok, isBool := m["is_absolute"].(bool); isBool && !ok {
relative = append(relative, str(m, "path"))
}
}
var b strings.Builder
if len(internal) > 0 {
fmt.Fprintf(&b, "· %d 条是 Agent 桥的内部会话目录(投到那里会把会话的工作区变成它):%s\n",
len(internal), firstFew(internal, 3))
}
if len(relative) > 0 {
fmt.Fprintf(&b, "· %d 条是相对路径(与同名绝对路径是两个不同工作区):%s\n",
len(relative), firstFew(relative, 3))
}
return b.String()
}
func firstFew(items []string, n int) string {
if len(items) <= n {
return strings.Join(items, ", ")
}
return strings.Join(items[:n], ", ") + fmt.Sprintf(" …(共 %d 条)", len(items))
}