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

462 lines
15 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)
}
if relay := str(payload, "duplicate_relay"); relay != "" {
fmt.Fprintf(&b, "。注意:%s", relay)
}
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"])))
}
fmt.Fprintf(&b, "· %s\n 工作区:%s\n 标题:%s%s\n",
str(m, "address"), str(m, "path"),
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))
}