Files
MailUI4Agents/server/internal/mcp/tools.go
JianFeeeee 11654f88b5 fix(mcp)★★: 未声明参数不再被静默吞掉 —— 模型拿到假成功
## 实测经过(全面测 MCP 时发现)

`connect_to_server(gateway_url="http://evil.example", agent_key="x")`
返回「已连接」—— 看起来像模型改了网关坐标。

查下去坐标**改不了**:身份只从 AgentAuth 的 context 取,参数被忽略
(schema 是 `properties: {}`)。**所以不是安全问题。**

但它是另一种毛病:**误传/写错的参数被静默吞掉**,模型收到「成功」后
不会知道自己传的东西根本没生效。实测发现 **11 个工具全部**没设
`additionalProperties: false`。

## 两道都补

1. **schema 层**:`objSchema` 加 `additionalProperties: false`
   —— 一处改动覆盖全部 11 个工具。
2. **运行时**:`unknownArgs` / `declaredArgs` 在 `tools/call` 里再拒一次。
   schema 只是**声明**,是否生效取决于宿主是否遵守 ⇒ 这一道不依赖宿主。

报错文案点名参数并列出可用参数,让模型能自我纠正:

    工具 connect_to_server 没有这些参数:agent_key、gateway_url(可用:(无参数))

`unknownArgs` 只在 schema 自己声明了 `additionalProperties:false` 时才判 ——
否则等于替宿主做主,把合法的透传参数也拒了(有判据钉住)。

## 判据自己假绿了一次

变异 `if false && len(bad) > 0`(短路调用点)→ **0 红**。

因为 `TestUnknownArgsOnlyWhenSchemaOptsIn` 测的是 `unknownArgs` **这个函数**,
而**函数正确 ≠ 它被接上了**。这是本轮第五次栽在「形状判据测不到接线」上。

⇒ 补了端到端一格:从 HTTP 进去,看客户端实际收到什么(并断言绝不能是
「已连接」—— 那正是要修的假成功)。重测同一变异立刻转红。

判据走真注册表(`NewTools().RegisterAll`)而非复制一份清单 ——
复制的那份会与真实工具面漂移,而漂移方向恰好是「新工具没被判据覆盖」。

## 顺带纠正上一轮的一个错误结论

上轮我说 `read_mail` 与 `read_thread` 拒绝措辞不一致是缺陷。**那是错的。**

`agent_discovery.go:414` 明确丢掉 reason,注释写着「拒绝原因在 403 文案上
合流成同一句,反而少泄一点信息」—— 那是**刻意的安全取舍**,不告诉调用方
到底是跨工作区还是没参与过(两者指向同一个可枚举的信息)。

而 `read_mail` 那句话是用户 2026-09-15 亲自订正过的(原注释:旧文案让
zcode 推断出「那五位 agent 不存在」,把错误结论写进了给人看的信里)。
⇒ **不改**。把刻意的安全设计当缺陷「修」掉,比留着更糟。

14 包全绿。
2026-10-04 17:08:15 +08:00

288 lines
9.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。
/*
objSchema 构造工具的 inputSchema。
★ `additionalProperties: false` 是 2026-10-04 实测后加的,不是洁癖:
实测 `connect_to_server(gateway_url="http://evil.example", agent_key="x")`
返回「已连接」—— 看起来像模型改了网关坐标。查下去它确实改不了
(身份只从 AgentAuth 的 context 取,参数被忽略),所以**不是安全问题**。
但它是另一种毛病:**误传/写错的参数被静默吞掉**,模型收到「成功」后
不会知道自己传的东西根本没生效。实测里 11 个工具**全部**没设这个标记。
⇒ 未声明的参数必须在 schema 层就拒绝,让模型立刻看到
「这个工具没有这个参数」而不是拿到一个假的成功。
(跨版本宿主仍可能不发 `additionalProperties`,
故 tools/call 侧另有一道运行时校验,见 checkToolArgs。)
*/
func objSchema(props map[string]any, required ...string) map[string]any {
s := map[string]any{
"type": "object",
"properties": props,
"additionalProperties": false,
}
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()
}