package mcp /* 未声明参数必须被拒绝(2026-10-04 实测驱动)。 ★ 实测经过: `connect_to_server(gateway_url="http://evil.example", agent_key="x")` 返回「已连接」。查下去**坐标改不了**(身份只从 AgentAuth 的 context 取, 参数被忽略)—— 所以不是安全问题。 但它是另一种毛病:**误传/写错的参数被静默吞掉**,模型拿到一个假成功, 不会知道自己传的东西根本没生效。实测 11 个工具**全部**没设 `additionalProperties: false`。 ⇒ 两道: 1. schema 层 `additionalProperties: false`(声明) 2. tools/call 运行时校验(不依赖宿主是否遵守 schema) */ import ( "encoding/json" "net/http" "net/http/httptest" "sort" "strings" "testing" ) func TestEveryToolDeclaresAdditionalPropertiesFalse(t *testing.T) { for name, tl := range allTools() { sch := tl.Schema().InputSchema if sch == nil { t.Errorf("工具 %s 没有 inputSchema", name) continue } allowed, ok := sch["additionalProperties"].(bool) if !ok || allowed { t.Errorf("★ 工具 %s 的 schema 未设 additionalProperties:false ⇒ "+ "误传参数会被静默吞掉,模型拿到假成功", name) } } } func TestConnectToServerRejectsCoordinateOverride(t *testing.T) { // ★ 这正是实测里被吞掉的那次调用:模型以为改了网关坐标。 sch := toolByName(t, "connect_to_server").Schema().InputSchema args := map[string]any{ "gateway_url": "http://evil.example", "agent_key": "x", } bad := unknownArgs(sch, args) sort.Strings(bad) if len(bad) != 2 || bad[0] != "agent_key" || bad[1] != "gateway_url" { t.Fatalf("★ 应识别出 2 个未声明参数,实际 %v", bad) } if got := declaredArgs(sch); got != "(无参数)" { t.Errorf("connect_to_server 无参,报错文案应说「(无参数)」,实际 %q", got) } } func TestUnknownArgsOnlyWhenSchemaOptsIn(t *testing.T) { // schema 没声明 additionalProperties:false 时**不得**判 —— // 否则等于替宿主做主,把合法的透传参数也拒了。 permissive := map[string]any{ "type": "object", "properties": map[string]any{"a": map[string]any{"type": "string"}}, } if bad := unknownArgs(permissive, map[string]any{"zzz": 1}); len(bad) != 0 { t.Errorf("schema 未 opt-in 时不该判未声明参数,实际 %v", bad) } // opt-in 后才判 strict := map[string]any{ "type": "object", "properties": map[string]any{"a": map[string]any{"type": "string"}}, "additionalProperties": false, } if bad := unknownArgs(strict, map[string]any{"zzz": 1, "a": "x"}); len(bad) != 1 || bad[0] != "zzz" { t.Errorf("opt-in 后应只报 zzz,实际 %v", bad) } } func TestDeclaredArgsListsAllSorted(t *testing.T) { sch := objSchema(map[string]any{ "zeta": strProp("z"), "alpha": strProp("a"), "mid": strProp("m"), }) if got, want := declaredArgs(sch), "alpha、mid、zeta"; got != want { t.Errorf("declaredArgs 应按字典序列出全部,实际 %q(期望 %q)", got, want) } // 空 schema 要说「(无参数)」而不是空串 —— // 报错文案里出现空括号会让模型以为还有别的参数没列出来。 if got := declaredArgs(objSchema(map[string]any{})); !strings.Contains(got, "无参数") { t.Errorf("空 schema 的 declaredArgs 应说明无参数,实际 %q", got) } if got := declaredArgs(nil); got != "(无参数)" { t.Errorf("nil schema 不该 panic,实际 %q", got) } } // 确认 schema 能正常序列化(additionalProperties 不引入非 JSON 值) func TestSchemaStillMarshalsCleanly(t *testing.T) { b, err := json.Marshal(toolByName(t, "send_mail").Schema().InputSchema) if err != nil { t.Fatalf("序列化失败: %v", err) } if !strings.Contains(string(b), `"additionalProperties":false`) { t.Errorf("序列化后应带 additionalProperties:false,实际 %s", b) } } // allTools 返回全部已注册工具(名字 → 工具)。 // // 直接调注册表而不是复制一份清单:复制的那份会与真实工具面漂移, // 而漂移的方向恰好是「新工具没被判据覆盖」。 func allTools() map[string]Tool { s := NewServer(nil) NewTools().RegisterAll(s) // ★ 必须走真注册:复制一份清单会与真实工具面漂移, // 而漂移方向恰好是「新工具没被判据覆盖」。 m := map[string]Tool{} for _, n := range s.toolNames() { m[n] = s.tools[n] } return m } func toolByName(t *testing.T, name string) Tool { t.Helper() tl := allTools() tl2, ok := tl[name] if !ok { t.Fatalf("找不到工具 %s(已注册:%v)", name, keysOf(tl)) } return tl2 } func keysOf(m map[string]Tool) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } sort.Strings(out) return out } /* ★ 端到端一格:tools/call **真的**拒绝未声明参数。 为什么必须单独一格(2026-10-04): 上面 `TestUnknownArgsOnlyWhenSchemaOptsIn` 测的是 `unknownArgs` 这个**函数**, 而变异 `if false && len(bad) > 0`(把调用点短路)时它**依然全绿** —— 判据测的是被测函数的正确性,不是**它被接上了**。 ⇒ 形状/单函数判据防不住「函数正确但没调用」。 这一格从 HTTP 进去,看客户端实际收到什么。 */ func TestToolsCallRejectsUnknownArgEndToEnd(t *testing.T) { s := NewServer(nil) NewTools().RegisterAll(s) body := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":` + `{"name":"connect_to_server","arguments":{"gateway_url":"http://evil.example"}}}` rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/v1/mcp", strings.NewReader(body)) s.HandleHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("应 200(JSON-RPC 层),实际 %d:%s", rec.Code, rec.Body.String()) } out := rec.Body.String() if !strings.Contains(out, "gateway_url") || !strings.Contains(out, "没有这些参数") { t.Errorf("★ tools/call 应点名 gateway_url 是未声明参数,实际返回:%s", out) } // ★ 绝不能是「已连接」—— 那正是这次要修的假成功。 if strings.Contains(out, "已连接") { t.Errorf("★ 回成了假成功:%s", out) } // 合法调用(无参)仍须成功,别把门关死。 okBody := `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":` + `{"name":"connect_to_server","arguments":{}}}` rec2 := httptest.NewRecorder() req2 := httptest.NewRequest(http.MethodPost, "/api/v1/mcp", strings.NewReader(okBody)) s.HandleHTTP(rec2, req2) if strings.Contains(rec2.Body.String(), "没有这些参数") { t.Errorf("无参调用不该被拒:%s", rec2.Body.String()) } }