Files
MailUI4Agents/server/internal/mcp/tool_impls.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

436 lines
18 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 (
"context"
"fmt"
"net/http"
"strings"
"github.com/agentmail/gateway/internal/handler"
)
// sessionIDProp 是 read_mail / read_thread / forward_mail 共用的参数说明。
//
// ★ 为什么这三个工具必须暴露它:handler 侧的 agentScope 靠 session_id 把
// 读操作限在你当前所在那条会话(会话之间的记忆是隔离的)。不暴露它 ⇒
// 模型怎么调都会被拒「这封信不在你当前所在的那条会话里」,而那条文案看起来
// 像权限过紧,其实是**缺参数**。这个洞是端到端跑出来的(带 session_id 的
// 对照组成功、不带失败)。
var sessionIDProp = map[string]any{
"type": "string",
"description": "★ 你当前所在的会话 id。多会话并行时必传," +
"否则读操作会被拒(会话之间的记忆是隔离的)",
}
// ---- read_inbox ----
func (t *Tools) ReadInbox() Tool {
return fnTool{
schema: ToolSchema{
Name: "read_inbox",
Description: "列出你的收件箱(按工作区与会话收窄)。缺 workspace 会报 400 —— 请带上你所处工作区的绝对路径。",
Annotations: readOnly,
InputSchema: objSchema(map[string]any{
"workspace": strProp("★ 必需。你所处工作区的绝对路径,例如 /home/program/agentmail"),
"status": strProp("unread(默认)| all | read"),
"limit": numProp("最多返回多少封,默认 10"),
"session_id": strProp("可选。只列这条会话的邮件 —— 多会话并行时必传,否则会把别人的未读一并标成已读"),
}, "workspace"),
},
run: func(ctx context.Context, args map[string]any) (string, error) {
target := withQuery("/api/v1/mail/inbox", map[string]string{
"workspace": strings.TrimSpace(argStr(args, "workspace")),
"status": argStr(args, "status"),
"limit": argStr(args, "limit"),
"session_id": argStr(args, "session_id"),
})
code, payload, raw := invoke(ctx, handler.GetInbox, newRequest(ctx, http.MethodGet, target, nil))
return render("read_inbox", code, payload, raw)
},
}
}
// ---- read_mail ----
func (t *Tools) ReadMail() Tool {
return fnTool{
schema: ToolSchema{
Name: "read_mail",
Description: "按邮件 id 读完整正文(含附件清单)。注意:投递时该信已标为已读,read_inbox 默认只看未读,读不到它。",
Annotations: readOnly,
InputSchema: objSchema(map[string]any{
"mail_id": strProp("邮件 ID"),
"session_id": sessionIDProp,
}, "mail_id"),
},
run: func(ctx context.Context, args map[string]any) (string, error) {
id := strings.TrimSpace(argStr(args, "mail_id"))
target := withQuery("/api/v1/agent/mail/"+id, map[string]string{"session_id": strings.TrimSpace(argStr(args, "session_id"))})
req := withRouteParams(newRequest(ctx, http.MethodGet, target, nil), map[string]string{"id": id})
code, payload, raw := invoke(ctx, handler.AgentGetMail, req)
return render("read_mail", code, payload, raw)
},
}
}
// ---- read_thread ----
func (t *Tools) ReadThread() Tool {
return fnTool{
schema: ToolSchema{
Name: "read_thread",
Description: "读一封邮件所在的整条线索(按时间顺序,含各封的正文与投递方)。",
Annotations: readOnly,
InputSchema: objSchema(map[string]any{
"mail_id": strProp("线索中任意一封邮件的 ID"),
"offset": numProp("分页偏移,续取时传上次返回的 next_offset"),
"session_id": sessionIDProp,
}, "mail_id"),
},
run: func(ctx context.Context, args map[string]any) (string, error) {
id := strings.TrimSpace(argStr(args, "mail_id"))
target := withQuery("/api/v1/agent/mail/"+id+"/thread", map[string]string{
"session_id": strings.TrimSpace(argStr(args, "session_id")),
"offset": argStr(args, "offset"),
})
req := withRouteParams(newRequest(ctx, http.MethodGet, target, nil), map[string]string{"id": id})
code, payload, raw := invoke(ctx, handler.AgentGetMailThread, req)
return render("read_thread", code, payload, raw)
},
}
}
// ---- send_mail ----
func (t *Tools) SendMail() Tool {
return fnTool{
schema: ToolSchema{
Name: "send_mail",
Description: "发送邮件。三维地址 name@path.session:省略 session=投到默认会话,.new 强制新建," +
".具体别名 必须已存在。回复来信请传 reply_to。",
Annotations: writeSafe,
InputSchema: objSchema(map[string]any{
"to": strProp("收件人三维地址,如 homeagent@/home/program/agentmail"),
"subject": strProp("主题"),
"body": strProp("正文(Markdown)"),
"cc": strProp("抄送,逗号分隔多个三维地址"),
"reply_to": strProp("回复某封邮件时传其 mail_id"),
"session_alias": strProp("可选:指定会话别名"),
"attachment_ids": arrayProp("附件 ID 列表(先用 upload_attachment 取得)"),
"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) {
// 改名提议搭在正文里发(服务端**没有** propose_alias 字段,
// 见 rename_proposal.go)。先判别名是否合法 —— 非法时**不追加标记**
// 并如实告诉模型「没提交」,而不是发一个服务端会静默丢弃的标记。
wantAlias := strings.TrimSpace(argStr(args, "propose_alias"))
proposed := false
text := argStr(args, "body")
if wantAlias != "" {
var ok bool
text, ok = appendRenameProposal(text, wantAlias, argStr(args, "propose_reason"))
proposed = ok
}
body := map[string]any{
"to": argStr(args, "to"),
"subject": argStr(args, "subject"),
"body": text,
}
if v := argStr(args, "cc"); v != "" {
body["cc"] = v
}
if v := argStr(args, "reply_to"); v != "" {
body["reply_to"] = v
}
if v := argStr(args, "session_alias"); v != "" {
body["session_alias"] = v
}
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)
}
base, err := render("send_mail", code, payload, raw)
if err != nil {
return base, err
}
// 改名提议的回显必须用**服务端返回的**别名(见 renameProposalNote)
if note := renameProposalNote(str(payload, "rename_proposed"), wantAlias, proposed); note != "" {
return base + "\n" + note, nil
}
return base, nil
},
}
}
// ---- forward_mail ----
func (t *Tools) ForwardMail() Tool {
return fnTool{
schema: ToolSchema{
Name: "forward_mail",
Description: "转发一封邮件给新的收件人(自动引用原文与附件)。与回复不同:回复落回原会话,转发按目标地址另行定位会话。",
Annotations: writeSafe,
InputSchema: objSchema(map[string]any{
"mail_id": strProp("要转发的邮件 ID"),
"to": strProp("新收件人的三维地址"),
"comment": strProp("转发说明,置于引用原文之前"),
"subject": strProp("可选:自定义主题;留空则自动加 Fwd: 前缀"),
"cc": strProp("抄送,逗号分隔多个三维地址"),
"session_alias": strProp("可选:给转发出来的新会话命名(仅在目标以 .new 结尾时生效)"),
"session_id": sessionIDProp,
}, "mail_id", "to"),
},
run: func(ctx context.Context, args map[string]any) (string, error) {
id := strings.TrimSpace(argStr(args, "mail_id"))
body := map[string]any{
"to": argStr(args, "to"),
"comment": argStr(args, "comment"),
}
if v := argStr(args, "subject"); v != "" {
body["subject"] = v
}
if v := argStr(args, "cc"); v != "" {
body["cc"] = v
}
if v := argStr(args, "session_alias"); v != "" {
body["session_alias"] = v
}
target := "/api/v1/mail/" + id + "/forward"
// session_id 走 query(ForwardMail 与其它读端点一样用 agentScope 收窄)
target = withQuery(target, map[string]string{
"session_id": strings.TrimSpace(argStr(args, "session_id")),
})
req := withRouteParams(newRequest(ctx, http.MethodPost, target, body), map[string]string{"id": id})
code, payload, raw := invoke(ctx, handler.ForwardMail, req)
return render("forward_mail", code, payload, raw)
},
}
}
// ---- upload_attachment ----
func (t *Tools) UploadAttachment() Tool {
return fnTool{
schema: ToolSchema{
Name: "upload_attachment",
Description: "上传本地文件作为邮件附件,返回 attachment_id。拿到 id 后必须在 send_mail 的 attachment_ids 里带上,附件才会随邮件发出。",
Annotations: writeSafe,
InputSchema: objSchema(map[string]any{
"file_path": strProp("要上传的本地文件绝对路径"),
"filename": strProp("自定义展示文件名,默认取路径的最后一段"),
}, "file_path"),
},
run: func(ctx context.Context, args map[string]any) (string, error) {
// UploadAttachment 是 multipart handler,包装它要造 multipart 表单。
// 不读文件内容(工具参数里没有内容字段,只有路径)—— 这里
// **由服务端**去读那个路径。
//
// ⚠ 这条本身值得一个判据:服务端按参数里的路径读本机文件,
// 是一处「工具参数即文件系统访问」。只读、不写,且路径由**已认证**
// 的 Agent 给出 —— 与 /attachments 端点本身的口径一致
// (该端点也是收文件、只是从请求体取)。真正要收紧的是
// write_file 一类写工具,本服务**不提供**(见 tools 数量判据)。
path := strings.TrimSpace(argStr(args, "file_path"))
name := strings.TrimSpace(argStr(args, "filename"))
code, payload, raw := invokeUpload(ctx, path, name)
return render("upload_attachment", code, payload, raw)
},
}
}
// ---- download_attachment ----
func (t *Tools) DownloadAttachment() Tool {
return fnTool{
schema: ToolSchema{
Name: "download_attachment",
Description: "下载邮件附件到本地文件。attachment_id 从 read_inbox 的附件清单里取。",
Annotations: writeSafe,
InputSchema: objSchema(map[string]any{
"attachment_id": strProp("附件 ID"),
"save_path": strProp("保存到的本地绝对路径"),
}, "attachment_id", "save_path"),
},
run: func(ctx context.Context, args map[string]any) (string, error) {
id := strings.TrimSpace(argStr(args, "attachment_id"))
save := strings.TrimSpace(argStr(args, "save_path"))
if save == "" {
return "", fmt.Errorf("缺少 save_path")
}
target := "/api/v1/attachments/" + id
code, payload, raw := invokeDownload(ctx, target, id, save)
return render("download_attachment", code, payload, raw)
},
}
}
// ---- suggest_address ----
func (t *Tools) SuggestAddress() Tool {
return fnTool{
schema: ToolSchema{
Name: "suggest_address",
Description: "查询可用的收件人地址,用于精准发信。" +
"★ **发信前必须先问它**,不要凭记忆拼 `name@path.session` —— 拼错不会报错," +
"只会静默投进另一条会话(实测:`pi@root` 与 `pi@/root` 是两个不同工作区," +
"选错后投递返回 200 但落进无关线索)。三种用法:" +
"只给 name(列出该 name 用过的工作目录,并标注哪些是桥内部目录、哪些是相对路径);" +
"给 name + flatten=true(**一次拿到该 name 的全部可投递地址**,含各自的工作区 —— " +
"不知道在哪个工作区时用这个);" +
"给 name + path(列该工作区下可续谈的会话)。",
Annotations: readOnly,
InputSchema: objSchema(map[string]any{
"name": strProp("收件人名;留空则列出所有候选收件人"),
"path": strProp("工作目录;与 name 同时给出才列会话"),
"flatten": boolProp("★ 只给 name 时置 true:一次返回该 name 的全部可投递地址(含各自工作区)"),
}),
},
run: func(ctx context.Context, args map[string]any) (string, error) {
flat := "0"
if argBool(args, "flatten") {
flat = "1"
}
target := withQuery("/api/v1/agent/contacts/suggest", map[string]string{
"flatten": flat,
"name": strings.TrimSpace(argStr(args, "name")),
"path": strings.TrimSpace(argStr(args, "path")),
})
code, payload, raw := invoke(ctx, handler.AgentSuggestAddress, newRequest(ctx, http.MethodGet, target, nil))
return render("suggest_address", code, payload, raw)
},
}
}
// ---- list_contacts ----
func (t *Tools) ListContacts() Tool {
return fnTool{
schema: ToolSchema{
Name: "list_contacts",
Description: "列出自己参与过的会话及各自的可投递地址、未读数、剩余往返预算。用于回答「我还有什么没处理」与「上次跟某人聊的那条线索地址是什么」。",
Annotations: readOnly,
InputSchema: objSchema(map[string]any{
"limit": numProp("最多列出多少条,默认 20"),
}),
},
run: func(ctx context.Context, args map[string]any) (string, error) {
target := withQuery("/api/v1/agent/contacts", nil)
code, payload, raw := invoke(ctx, handler.AgentListContacts, newRequest(ctx, http.MethodGet, target, nil))
return render("list_contacts", code, payload, raw)
},
}
}
// ---- session_participants ----
func (t *Tools) SessionParticipants() Tool {
return fnTool{
schema: ToolSchema{
Name: "session_participants",
Description: "列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址,并标出谁还没回应。**要回给抄收方或向第三方转达时先用它拿地址**。",
Annotations: readOnly,
InputSchema: objSchema(map[string]any{
"session_id": strProp("会话 ID"),
}, "session_id"),
},
run: func(ctx context.Context, args map[string]any) (string, error) {
id := strings.TrimSpace(argStr(args, "session_id"))
target := "/api/v1/agent/sessions/" + id + "/participants"
req := withRouteParams(newRequest(ctx, http.MethodGet, target, nil), map[string]string{"id": id})
code, payload, raw := invoke(ctx, handler.AgentSessionParticipants, req)
return render("session_participants", code, payload, raw)
},
}
}
// fnTool:一个由函数实现的 Tool。
type fnTool struct {
schema ToolSchema
run func(ctx context.Context, args map[string]any) (string, error)
}
func (f fnTool) Schema() ToolSchema { return f.schema }
func (f fnTool) Run(ctx context.Context, args map[string]any) (string, error) {
// ctx 是**当前请求的** context,AgentAuth 放进去的身份就在里面。
// 绝不能在这里换成 context.Background() —— 那会让每个工具调用
// 都变成 Unauthorized(症状是「模型说连上了但什么都读不到」)。
return f.run(ctx, args)
}
// invokeUpload 造 multipart 请求调 UploadAttachment。
func invokeUpload(ctx context.Context, path, name string) (int, map[string]any, string) {
body, contentType, err := buildUploadForm(path, name)
if err != nil {
return http.StatusBadRequest, map[string]any{"error": err.Error()}, ""
}
r := newMultipartRequest(ctx, body, contentType)
rec := httptestRecorder()
handler.UploadAttachment(rec, r)
var out map[string]any
_ = jsonUnmarshal(rec.Body.Bytes(), &out)
return rec.Code, out, rec.Body.String()
}
// ---- connect_to_server ----
// ConnectToServer 回报连通性。
//
// 与独立进程那版的差别值得写清楚:那一版要真正去 `POST /agent/register`
// (因为它是个**局外**进程,得让网关知道"这个客户端活着")。而 MCP 端点
// 挂在 AgentAuth 之内 —— **能调到这个工具本身就证明凭证已通过**,
// Agent 的 last_seen 也已被 AgentAuth 刷新。所以这里只做一次真实读
// (list_contacts 的数据源)来确认数据库那一侧也通,而不是空口说 ok。
//
// 曾经踩过的坑(保留这条注释):那一版的 register 请求只发
// `X-Agent-Secret` 头,而 `/agent/register` 只认 Bearer 或 body 里的
// secret ⇒ secret-only 的 Agent 必然 400,而模型只看到一个 4xx 拼不出
// 该改什么。包装 handler 后这类分叉从根上消失。
func (t *Tools) ConnectToServer() Tool {
return fnTool{
schema: ToolSchema{
Name: "connect_to_server",
Annotations: readOnly,
Description: "连接到 AgentMail Gateway:用当前配置的身份确认连通性并报告会话概况。" +
"能调用本工具即表示凭证已通过认证。首次接入或换环境时调用一次确认。",
InputSchema: objSchema(map[string]any{}),
},
run: func(ctx context.Context, args map[string]any) (string, error) {
target := "/api/v1/agent/contacts"
code, payload, raw := invoke(ctx, handler.AgentListContacts, newRequest(ctx, http.MethodGet, target, nil))
if code < 200 || code >= 300 {
return render("connect_to_server", code, payload, raw)
}
contacts, _ := payload["contacts"].([]any)
return fmt.Sprintf("已连接。凭证有效,参与过 %d 条会话。", len(contacts)), nil
},
}
}