Files
MailUI4Agents/server/internal/mcp/tool_impls.go
JianFeeeee 457d1608f0 feat(mcp): MCP 集成进网关本体 —— POST /api/v1/mcp(Streamable HTTP)
## 为什么要集成而不是独立进程

上一版(29ad8aa)是独立进程 `plugins/zcode-mail-bridge/mcp/server.mjs`,
用 HTTP 调本网关。四条真实成本:

1. **工具语义有两份**。桥里的 read_inbox / send_mail 是**手抄**网关的,
   抄错就是行为分叉 —— 已抓到两次:`connect_to_server` 只发
   `X-Agent-Secret` 头,而 `/agent/register` 只认 Bearer 或 body 里的
   secret ⇒ secret-only 的 Agent 必然 400。
2. **鉴权与收窄要再实现一遍**。工作区收窄、会话收窄、冷静期、配额住在服务端。
3. **多一跳 + 多一个故障点**。
4. **接入端仍要装东西**(node + 桥 + 环境变量)。

现在:工具**包装现有 handler**,同一份代码、同一套鉴权与收窄;
接入端只填一个 URL。

## 传输与实现(用户裁定)

- **Streamable HTTP**(规范 2025-06-18):单端点 POST,通知回 202,
  请求回 JSON-RPC。
- **包装 handler**(不是直调 repo):`newRequest` + `invoke` 造内部请求
  交给 `handler.GetInbox` / `SendMail` / … 于是 `AgentMayReadSession`、
  冷静期、配额、附件保护目录全部是同一条代码路径,不是复述。
- 手写零依赖 JSON-RPC(协议面只有 4 个方法),与本仓取向一致。

端点挂在 `AgentAuth` **之内**:必须与 /mail/send 同一套凭证,
否则就成了绕过收窄的旁门。

## 11 个工具,名字与参数与四桥逐字一致

`connect_to_server` 在这里只做一次真实读来确认连通性 —— 能调到它本身
就证明凭证已过(它是局内端点,不再需要 register)。

## ★ 端到端撞出并修掉的两个真 bug

**① `Tool.Run` 丢掉了身份**(本来写成 `context.Background()`)。
症状:每个工具调用都 Unauthorized,模型表现为「说连上了但读不到任何信」。

**② 路径参数没到位**:被包装的 handler 用 `chi.URLParam(r,"id")` 取 id,
而 `httptest.NewRequest` 造的请求**没过 chi 的路由** ⇒ `URLParam` 恒空
⇒ 任何带路径参数的工具都报「Invalid id」。

第②个的发现过程值得记:端到端测越权时,主人和越权者**都**返回
「Invalid id」。只看越权那一次会误判成「收得太紧」,进而把**正确的收窄改松**;
做对照才看出是参数没到位。

修法两处:`withRouteParams` 注入 chi RouteContext;`invoke` 里**不能**再
`WithContext(ctx)` —— 那会覆盖掉刚注入的 RouteContext。

**③ 发现并暴露了会话越权漏洞**(同批,单独提交 095213b):
`AgentMayReadSession` 只比 `scope == target`,不问「你是不是参与方」,
而 session_id 由请求方给。对照实验 + 生产复核证实可读他人正文。

## 判据(13 格)

`internal/mcp/mcp_test.go`。真正在钉三件**只有集成才可能坏**的事:

1. MCP 不能成为越权旁门(工具参数里没有身份字段)。
2. 参数映射不许偷偷放宽/收紧(`attachment_ids` 被吞 ⇒ 附件静默不随信发出)。
3. 协议语义不许退化(工具失败必须 result+isError,不是 JSON-RPC error)。

`TestEveryErrorResponseCarriesID` 是被真 bug 逼出来的:曾用
`ID json.RawMessage` + `omitempty`,nil 时**整个 id 字段从 JSON 里消失**,
客户端会一直等这条的响应。遍历全部错误出口逐条验。

**变异验证**:

    Run 丢身份                    → 红 4
    工具失败回 JSON-RPC error     → 红 4
    read_inbox 丢 workspace 收窄  → 红 1
    id 泄露(tag+idPtr 同时失效) → 红 1 ★(真 bug 需两处同时失效,故两处防御都要留)
    去掉 withRouteParams          → 红 1
    invoke 里加回 WithContext     → 红 1

## 端到端(真实网关进程,临时库,备用端口 8199,不动生产)

    未认证 /mcp              → 401
    错误密钥                 → 401
    initialize               → 回显 2025-06-18
    notifications/initialized→ 202 且无响应体
    tools/list               → 11 个,带 annotations 与 required
    send_mail → read_inbox   → mcp-peer 通过 MCP 读到对方发来的信
    read_mail(带 session_id)→ 主人读到自己的信

## 未做

- 未删除旧桥 `plugins/zcode-mail-bridge/mcp/server.mjs`。它是 zcode 插件
  清单里声明的入口(`.zcode-plugin/plugin.json` 的 mcpServers),删掉会破坏
  该插件的组装。两者并存无害:桥仍走 HTTP,服务端这份是接入端零安装的那条路。
- 未部署(本提交只含代码)。
2026-10-02 13:28:07 +08:00

365 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 (
"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"),
"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"))})
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 取得)"),
}, "to"),
},
run: func(ctx context.Context, args map[string]any) (string, error) {
body := map[string]any{
"to": argStr(args, "to"),
"subject": argStr(args, "subject"),
"body": argStr(args, "body"),
}
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))
return render("send_mail", code, payload, raw)
},
}
}
// ---- 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("抄送,逗号分隔多个三维地址"),
}, "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
}
target := "/api/v1/mail/" + id + "/forward"
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
},
}
}