Files
MailUI4Agents/server/internal/mcp/tool_impls.go
JianFeeeee 5e312c6f5f feat(mcp): 补齐与四桥的三个参数缺口 —— 改名提议 / 线索翻页 / 转发命名
## 起因

做 MCP 与各桥的**参数级**对照(不是数量级)时,发现三处缺口。上一轮我
说过其中两处「服务端没有」—— **那是错的**,是我没查就下的结论:

| 缺口 | 真相 |
|---|---|
| `send_mail` 缺 `propose_alias` / `propose_reason` | ✅ 真缺口,且**不需要服务端字段** |
| `read_thread` 缺 `offset` | 服务端**早已支持**(`thread.go` 的 `intQuery(r,"offset",…)`),我漏传 |
| `forward_mail` 缺 `session_alias` / `session_id` | 服务端**早已支持**(`forward.go:33`),我漏传 |

三处都是「接上就行」,没有一处需要改服务端。

## ① 改名提议:为什么不是加个字段

`/mail/send` **没有** `propose_alias` 字段 —— 提议是**搭在正文里**发出去的:

    <!-- agentmail:rename-session alias="fix-login-leak" reason="定位到泄漏点" -->

服务端用正则摘出来、把标记从入库正文剥掉、把规范化后的别名回填到响应的
`rename_proposed`。载体选 HTML 注释的三个理由见 `lib/rename-proposal.js`:
react-markdown 默认不解析 raw HTML(没剥掉也不破版)、纯文本客户端里一行不碍事、
不与 Markdown 语法冲突。

所以在 Go 侧复刻了 `lib/rename-proposal.js`(三方插件共用那份)的三段逻辑:
`isProposableAlias` / `appendRenameProposal` / `renameProposalNote`。

## ★ 这一层的真正风险:跨语言镜像

格式差一个空格(或把双引号写成单引号),服务端正则就匹配不上,而**失败是
静默**的:邮件照常发出、提议凭空消失、模型以为自己提过了、下一封拿那个不存在的
别名寻址 → 404。

判据因此钉两件事:

- **能被服务端那个正则真的解出来** —— 直接 import `renameProposalRe`,
  不是另写一个(复制一份就放弃了「镜像」的意义)。
- **与 JS 版逐字节相同** —— 三个用例(含「理由里的双引号要去掉」)逐字符对照。

## ②③ 线索翻页与转发命名

`read_thread` 透传 `offset`(长线索不再只能拿首段);`forward_mail` 透传
`session_alias`(给转发出的新会话命名)与 `session_id`(与其它读端点一样过
`agentScope` 收窄)。

## 一处**故意**与桥不同的差异

`connect_to_server` 在桥侧有 `gateway_url` / `key_token`,MCP 侧保持无参 ——
网关内建端点**已认证**,改坐标是部署动作,不该由一次工具调用触发(桥侧能改是
因为它是局外进程)。判据里为此写了注释,防止将来有人"顺手补齐"。

## 判据(rename_proposal_test.go)

参数级对照那格钉「与其它桥逐字一致」——**缺参数不会报错**,只会让模型以为
该能力不存在,属静默缺陷。

**变异验证**:

    删掉 propose_alias 两行(回到缺口态)    → 红 1 ✓
    标记少一对引号(跨语言镜像写错)         → 红 2 ✓
    非法别名也追加标记(静默丢弃的来源)     → 红 2 ✓

第三条最要紧:别名不合法时**必须**不追加标记,否则发出一个服务端匹配得上却被
`validateSessionAlias` 拒掉的标记 —— 失败仍然是静默的。

## 两次判据自身缺陷(都记下来)

1. 「与 JS 版逐字节相同」那格最初用 Go 字符串字面量写期望值,`\n` 成了字面两字符
   ⇒ 判据错报红。代码是对的,判据错了。
2. 变异脚本只切掉 `strProp(…)` 的**第一行**、续行留在原地 ⇒ schema 仍合法 ⇒
   「0 红」。**没有采信那个 0**,改用完整锚点重测才拿到正确的红 1。

全量 14 包绿。
2026-10-02 14:37:09 +08:00

403 lines
17 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("改名理由,一句话,展示给用户看"),
}, "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
}
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 给它可用的工作目录;" +
"name+path 都带则给该目录下可续谈的会话与现成地址。**发信前应先用它确认地址**,不要凭记忆拼写 —— 拼错不会报错,只会投到别的会话。",
Annotations: readOnly,
InputSchema: objSchema(map[string]any{
"name": strProp("收件人名;留空则列出所有候选收件人"),
"path": strProp("工作目录;与 name 同时给出才列会话"),
}),
},
run: func(ctx context.Context, args map[string]any) (string, error) {
target := withQuery("/api/v1/agent/contacts/suggest", map[string]string{
"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
},
}
}