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

267 lines
8.8 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"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"github.com/go-chi/chi/v5"
)
// Tools 构造网关的 11 个 MCP 工具。
//
// # 为什么是**包装 handler**而不是直调 repo
//
// 早先那版 MCP 在独立进程里用 HTTP 调本网关,于是工具语义是**手抄**的一份:
// 参数、收窄、配额、错误文案各写一遍。实测已抓到后果 ——
// `connect_to_server` 只发 `X-Agent-Secret` 头,而 `/agent/register` 只认
// Bearer 或 body 里的 `secret`,于是 secret-only 的 Agent 调它必然 400,
// 而模型看到 401/400 拼不出该改什么。
//
// 包装 handler 之后:**只有一份语义**。工作区收窄(`GetInbox` 缺 workspace
// 直接 400)、会话收窄(`agentScope`)、Agent↔Agent 冷静期、配额、
// 附件保护目录 —— 全部是同一条代码路径,不是复述。
//
// # 身份从哪来
//
// 不从工具参数里取身份。`AgentAuth` 已把身份放进 request context
// (`middleware.AgentNameKey`),每个内部请求都带上它。所以工具**无法**
// 通过参数冒充别的 Agent —— 那正是 2026-10-02 修掉的越权形状
// (`AgentMayReadSession` 的 `if scope == nil { return true }`)。
type Tools struct{}
// NewTools 造工具集。
func NewTools() *Tools { return &Tools{} }
// RegisterAll 把 11 个工具注册进端点。
//
// 工具名与参数名与 pi / dsh / opencode / zcode 四桥**逐字一致** —— 同一件事
// 在任何平台上必须是同一种做法,否则会出现「只在这个平台上模型不会回信」
// 这类单平台复现、排查代价最高的问题。
func (t *Tools) RegisterAll(s *Server) {
s.RegisterAll(
t.ReadInbox(), t.ReadMail(), t.ReadThread(), t.SendMail(),
t.ForwardMail(), t.UploadAttachment(), t.DownloadAttachment(),
t.SuggestAddress(), t.ListContacts(), t.SessionParticipants(),
t.ConnectToServer(),
)
}
// invoke 调一个 handler,拿到状态码与解码后的 JSON。
//
// 这是全部工具共用的骨架:造请求 → 交给 handler → 解读结果。
// handler 用 httptest.ResponseRecorder 收集(它是标准库类型,零依赖),
// 因为我们要的是"handler 的语义",不是真的走网络。
func invoke(ctx context.Context, h http.HandlerFunc, req *http.Request) (int, map[string]any, string) {
// ★ 不要在这里 `req = req.WithContext(ctx)`。
//
// req 身上已经挂了两样东西,而用 ctx 重建会**丢掉后面挂的那一样**:
// - 身份(middleware.AgentNameKey):由 newRequest 挂上
// - 路径参数(chi.RouteCtxKey):由 withRouteParams 挂上
//
// withRouteParams 之后调 WithContext(ctx),chi 的 RouteContext 就没了 ⇒
// handler 里 chi.URLParam 恒为空 ⇒ 报「Invalid id」。
// ctx 是**外层请求**的 context,而 req 已经是它的派生物(newRequest 里
// 做过一次 WithContext);所以这里什么都不做才是对的。
// (判据:TestPathParamsReachHandlers)
_ = ctx
rec := httptest.NewRecorder()
h(rec, req)
var out map[string]any
raw := rec.Body.Bytes()
_ = json.Unmarshal(raw, &out)
return rec.Code, out, string(raw)
}
// render 把 handler 的响应转成模型看的文本。
//
// 规则:成功时给可读摘要(而**不是**整坨 JSON —— 模型读 50 封邮件的 JSON
// 既费 token 又容易看错行);失败时**原样带上 handler 的错误文案**,
// 那是让人/模型能改道的信息,不能吞掉。
func render(name string, status int, payload map[string]any, raw string) (string, error) {
if status >= 200 && status < 300 {
return summarize(name, payload)
}
msg := errorMessage(payload)
if msg == "" {
msg = strings.TrimSpace(raw)
}
if msg == "" {
msg = fmt.Sprintf("HTTP %d", status)
}
return "", fmt.Errorf("%s", msg)
}
func errorMessage(payload map[string]any) string {
if payload == nil {
return ""
}
for _, k := range []string{"error", "message", "suggestion"} {
if v, ok := payload[k].(string); ok && v != "" {
return v
}
}
return ""
}
// ---- 参数取值 ----
//
// 工具参数一律宽容:数字既能是 JSON number 也能是字符串(模型两种都发过)。
// 严格解码会让一个本来能用的调用失败,而失败信息("类型不匹配")对模型没用。
func argStr(args map[string]any, key string) string {
if v, ok := args[key]; ok {
switch t := v.(type) {
case string:
return t
case float64:
// 纯整数的 number 当字符串用(id 常见地被模型写成数字)
if t == float64(int64(t)) {
return fmt.Sprintf("%d", int64(t))
}
return fmt.Sprintf("%v", t)
case bool:
return fmt.Sprintf("%t", t)
case nil:
return ""
}
}
return ""
}
func argInt(args map[string]any, key string, def int) int {
s := argStr(args, key)
if s == "" {
return def
}
var n int
if _, err := fmt.Sscanf(s, "%d", &n); err == nil {
return n
}
return def
}
// argBool 取布尔。字符串 "true"/"false" 也认。
func argBool(args map[string]any, key string) bool {
switch v := args[key].(type) {
case bool:
return v
case string:
return strings.EqualFold(strings.TrimSpace(v), "true")
}
return false
}
// argStrList 取字符串数组(逗号分隔的字符串也认)。
func argStrList(args map[string]any, key string) []string {
switch v := args[key].(type) {
case []any:
out := make([]string, 0, len(v))
for _, item := range v {
if s := argStr(map[string]any{"v": item}, "v"); s != "" {
out = append(out, s)
}
}
return out
case string:
parts := strings.FieldsFunc(v, func(r rune) bool {
return r == ',' || r == ';' || r == ' ' || r == '\n'
})
return parts
}
return nil
}
// withRouteParams 把路径参数注入 request context。
//
// ★ 为什么必须这么做:被包装的 handler 用 `chi.URLParam(r, "id")` 取路径参数
// (`pathUUID` → `uuid.Parse(chi.URLParam(r, "id"))`)。而我们用
// httptest.NewRequest 造的请求**没有经过 chi 的路由**,URLParam 恒为空串
// ⇒ 任何带路径参数的工具(read_mail / read_thread / forward_mail /
// download_attachment / session_participants)都会报「Invalid id」。
//
// 这个 bug 是**端到端**撞出来的:主人读自己的信与越权者读别人的信**都**返回
// 「Invalid id」。对照实验立刻说明这不是权限问题而是参数没到位 —— 如果只看
// 越权那一次,会误判成「收得太紧」,进而把正确的收窄改松。
//
// 用 chi 自己的 RouteContext 注入,而不是自造 context key:handler 读的是
// chi.URLParam,两边必须说同一种话。
func withRouteParams(r *http.Request, params map[string]string) *http.Request {
if len(params) == 0 {
return r
}
rctx := chi.NewRouteContext()
for k, v := range params {
rctx.URLParams.Add(k, v)
}
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
// objSchema 造一个 object 类型的入参 schema。
func objSchema(props map[string]any, required ...string) map[string]any {
s := map[string]any{"type": "object", "properties": props}
if len(required) > 0 {
s["required"] = required
}
return s
}
func strProp(desc string) map[string]any {
return map[string]any{"type": "string", "description": desc}
}
func numProp(desc string) map[string]any {
return map[string]any{"type": "integer", "description": desc}
}
func boolProp(desc string) map[string]any {
return map[string]any{"type": "boolean", "description": desc}
}
func arrayProp(desc string) map[string]any {
return map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": desc}
}
// 只读标注。宿主据此算风险等级并在 plan 档放行(漏传会被全拒)。
var readOnly = map[string]any{"readOnlyHint": true, "destructiveHint": false}
// 写但不破坏性(发信、转发:会改变别人的收件箱,但不删数据)。
var writeSafe = map[string]any{"readOnlyHint": false, "destructiveHint": false}
// hasWorkspaceArg 在没有 workspace 时给出**带例子**的错误。
//
// 直接复用 GetInbox 的错误文案即可(包装 handler 的好处):
// 那是唯一的口径,模型见一次就记住。
func newRequest(ctx context.Context, method, target string, body any) *http.Request {
var r *http.Request
if body != nil {
buf, _ := json.Marshal(body)
r = httptest.NewRequest(method, target, bytes.NewReader(buf))
r.Header.Set("Content-Type", "application/json")
} else {
r = httptest.NewRequest(method, target, nil)
}
// 身份从调用链的 context 继承(AgentAuth 放进去的)。
r = r.WithContext(ctx)
return r
}
// withQuery 在 URL 上加查询参数。
func withQuery(target string, kv map[string]string) string {
u, err := url.Parse(target)
if err != nil {
return target
}
q := u.Query()
for k, v := range kv {
if v != "" {
q.Set(k, v)
}
}
u.RawQuery = q.Encode()
return u.String()
}