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