Files
MailUI4Agents/server/internal/mcp/tool_impls.go
JianFeeeee 1b810a4898 fix(寻址)★★: 补「按 name 直出全部可投递地址」+ 标注 path 候选里的坑
## 起因

DSH 侧 Agent 报了一份寻址缺口(2026-10-02,全部结论有 API 实测复现)。
三段式寻址 `name@path.session` 里 session 段是**人的寻址入口**,而枚举它
必须先知道 path —— 但 path 恰恰是调用方无从得知的:

    给 name      → 只给 path(要再调一次才知道有哪些会话)
    给 name+path → 给会话别名(但 path 得先猜对)

于是一个闭合的环。报告实测的踩坑:投 `pi@root` 返回 **200**,落进一条标题
为「拓展坞实测硬件正常…」的无关会话 —— 投递成功,所以调用方不知道自己投错了。

## 修法

**① A 项:`flatten=1` 一次给出全部可投递地址**

`SuggestAddressesForPeer` + `suggest?name=&flatten=1`。每个候选自带
`path` 与可直接塞进 send_mail 的 `address` —— 调用方不必自己拼,
拼错就是那个「猜错比报错更糟」。

可见性口径**不放宽**,与原 name+path 那一支逐条一致(「我参与过 + 与该 name
匹配」)。报告本身也确认问题不在权限:同一批数据给了 path 就能列出 17 条。

按 path 分组平铺而非嵌套:嵌套时调用方要发一封「不知道在哪个 path」的信
仍得遍历全部组;平铺一次给全,模型不必做「先猜 path 再枚举」两步。

**② B/C 项:标注而非隐藏**

`paths[]` 每项带 `kind`(workspace / bridge-internal)与 `is_absolute`。

选标注不选过滤的理由:桥内部目录(`/root/.pi/mail-sessions/<uuid>`)
确实**是某些会话的真实 cwd**(实测那条 workspace='root' 的会话 uuid 正是
其中之一)—— 滤掉等于让那些会话彻底不可见;而留着不标,64 条候选里 33 条
是噪声,模型选中即静默投错(实测 64 条中 33 条是它)。

`suggestions` 保持原样与原顺序 —— SuggestPaths 按最近使用倒序
(刚用过的那个几乎总是下一封想用的),排序被打乱等于让模型取最老的那个。

## ★★ 顺带修掉一个生产级缺陷(实测撞出来的)

给 `SessionCandidate` 加 `LastActivity` 时用了:

    COALESCE(s.updated_at, '0001-01-01 00:00:00+00')

COALESCE 让驱动返回 **string**,扫进 time.Time 报 `unsupported Scan`
⇒ 命中 `return out, err` ⇒ **整个候选列表变空**(实测一条都列不出)。

生产影响:`updated_at` 为 NULL 的历史会话会全部静默消失。
而那个错误信息里**没有任何线索**指向「是你加的 COALESCE 害的」——
本次是我自己加的列触发的,排查花了几步。

改为扫进 `sql.NullTime`(NULL 即零值),平台镜像那条同理。
注释里写明为什么不能 COALESCE 兜底,免得下次有人再加回去。

## MCP 侧同步

`suggest_address` 加 `flatten` 参数,且**渲染必须单独写**:
flatten 的响应没有 `suggestions` 字段,走原来的分支只会回一句
「(没有 session_flat 建议)」—— 模型拿不到任何地址,等于白问一次。

path 形状的渲染把两类坑直接顶到眼前:桥内部目录、相对路径
(`root` 与 `/root` 在数据里是两个不同工作区,实测 1 条 vs 17 条)。

## 判据(8 格)

含「address 必须与候选自身 path/alias 一致」(那正是静默投错的解药)、
「两个工作区都要出现」(原形状缺的就是这一维)、
「不带 flatten 时行为一字未变」(各桥与 WebUI 都走那一支)、
「flatten 不得把 new 混在候选里」(没有真实会话时它看起来像出路)。

**变异验证**:

    COALESCE 兜底(那个真 bug)          → 红 1 ✓
    flatten 段放回 path=="" 之后(顺序 bug)→ 红 1 ✓(kind 变回 "path")

## 实测校准了一处报告里的数字

报告写「近似写法返回 0 条」,实测返回 **1 条,内容是 `new`** ——
服务端在任何 path 下都追加的新建占位。所以选错 path 时调用方看到的不是
「空」,而是「只有 new 可选」:**看起来像一条出路**,于是顺着它新建,
恰好落进猜错的那个工作区。比报 0 更危险(0 会让人停下,new 会让人继续)。

§E 无需修:`validateSessionAlias` 已拒绝别名含 `.`。

全量 14 包绿。
2026-10-02 15:59:56 +08:00

415 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@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
},
}
}