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,266 @@
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()
}