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,服务端这份是接入端零安装的那条路。
- 未部署(本提交只含代码)。
This commit is contained in:
2026-10-02 13:28:07 +08:00
parent 095213b981
commit 457d1608f0
6 changed files with 1921 additions and 0 deletions

View File

@ -0,0 +1,372 @@
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))),
}, ""
}