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:
266
server/internal/mcp/tools.go
Normal file
266
server/internal/mcp/tools.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user