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() }