## 为什么要集成而不是独立进程
上一版(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,服务端这份是接入端零安装的那条路。
- 未部署(本提交只含代码)。
373 lines
11 KiB
Go
373 lines
11 KiB
Go
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")
|
||
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))),
|
||
}, ""
|
||
}
|