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 包全绿。
This commit is contained in:
2026-10-04 12:54:35 +08:00
parent 7b0207b621
commit 11654f88b5
3 changed files with 259 additions and 1 deletions

View File

@ -37,6 +37,7 @@ import (
"io"
"log"
"net/http"
"sort"
"strings"
)
@ -312,6 +313,18 @@ func (s *Server) handleMessage(msg *rpcMessage, r *http.Request) *rpcMessage {
if args == nil {
args = map[string]any{} // 缺 arguments 当空对象,不抛错
}
// ★ 运行时再拒一次未声明参数。
//
// schema 里的 `additionalProperties: false` 只是**声明** ——
// 是否生效取决于宿主是否遵守。而实测中「参数被静默吞掉、模型拿到
// 假成功」正是最坏的一种失败:模型不会知道自己传的东西没生效。
// 这一道不依赖宿主。
sch := t.Schema().InputSchema
if bad := unknownArgs(sch, args); len(bad) > 0 {
return failure(id, rpcInvalidParams,
"工具 %s 没有这些参数:%s(可用:%s)",
p.Name, strings.Join(bad, "、"), declaredArgs(sch))
}
text, err := t.Run(r.Context(), args)
if err != nil {
s.log.Printf("[mcp] 工具 %s 失败:%v", p.Name, err)
@ -359,3 +372,40 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// unknownArgs 返回 args 里**未在 schema 声明**的键(已排序,保证报错稳定)。
//
// ★ 只在 schema 自己声明了 `additionalProperties: false` 时才判 ——
//
// 否则等于替宿主做主,把合法的透传参数也拒了。
func unknownArgs(schema map[string]any, args map[string]any) []string {
if schema == nil {
return nil
}
if allowed, ok := schema["additionalProperties"].(bool); !ok || allowed {
return nil
}
declared, _ := schema["properties"].(map[string]any)
bad := make([]string, 0)
for k := range args {
if _, ok := declared[k]; !ok {
bad = append(bad, k)
}
}
sort.Strings(bad)
return bad
}
// declaredArgs 列出工具声明过的参数(供报错文案用,避免模型去猜)。
func declaredArgs(schema map[string]any) string {
declared, _ := schema["properties"].(map[string]any)
if len(declared) == 0 {
return "(无参数)"
}
names := make([]string, 0, len(declared))
for k := range declared {
names = append(names, k)
}
sort.Strings(names)
return strings.Join(names, "、")
}