// mcp_test.go:/mcp initialize / tools/list / tools/call golden + 协议错误路径。 package gateway import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" ) // postMCP 以测试 key 发 MCP 请求。 func postMCP(e *testEnv, body string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, "/mcp", strReader(body)) req.Header.Set("X-Service-Token", e.plainKey) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() e.srv.Handler().ServeHTTP(rec, req) return rec } // TestMCPInitialize initialize golden:serverInfo + capabilities.tools + instructions。 func TestMCPInitialize(t *testing.T) { e := newTestEnv(t) rec := postMCP(e, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`) if rec.Code != http.StatusOK { t.Fatalf("want 200: %d %s", rec.Code, rec.Body.String()) } var resp struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id"` Result map[string]any `json:"result"` Error any `json:"error"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatal(err) } if resp.JSONRPC != "2.0" || string(resp.ID) != "1" || resp.Error != nil { t.Fatalf("initialize 响应头不符: %s", rec.Body.String()) } si, ok := resp.Result["serverInfo"].(map[string]any) if !ok || si["name"] != mcpServerName { t.Fatalf("serverInfo 不符: %v", resp.Result["serverInfo"]) } caps, ok := resp.Result["capabilities"].(map[string]any) if !ok || caps["tools"] == nil { t.Fatalf("capabilities.tools 缺失: %v", caps) } inst, _ := resp.Result["instructions"].(string) for _, frag := range []string{"法律法规", "登录墙", "整站搬迁", "个人信息", "region=overseas", "不要重试对抗"} { if !strings.Contains(inst, frag) { t.Fatalf("instructions 缺合规条目 %q", frag) } } } // TestMCPToolsList tools/list golden:search/read schema 与 mcp-usage §2 一致。 func TestMCPToolsList(t *testing.T) { e := newTestEnv(t) rec := postMCP(e, `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`) if rec.Code != http.StatusOK { t.Fatalf("want 200: %d", rec.Code) } var resp struct { Result struct { Tools []struct { Name string `json:"name"` InputSchema map[string]any `json:"inputSchema"` } `json:"tools"` } `json:"result"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatal(err) } if len(resp.Result.Tools) != 2 { t.Fatalf("工具数 %d ≠ 2: %s", len(resp.Result.Tools), rec.Body.String()) } byName := map[string]map[string]any{} for _, tl := range resp.Result.Tools { byName[tl.Name] = tl.InputSchema } sc, ok := byName["search"] if !ok { t.Fatal("缺 search 工具") } props := sc["properties"].(map[string]any) for _, k := range []string{"query", "region", "max_results", "time_range", "lang"} { if _, ok := props[k]; !ok { t.Fatalf("search schema 缺 %s", k) } } if req, ok := sc["required"].([]any); !ok || len(req) != 2 { t.Fatalf("search required 应为 [query, region]: %v", sc["required"]) } rc, ok := byName["read"] if !ok { t.Fatal("缺 read 工具") } rprops := rc["properties"].(map[string]any) for _, k := range []string{"url", "formats", "max_chars", "extract"} { if _, ok := rprops[k]; !ok { t.Fatalf("read schema 缺 %s", k) } } } // TestMCPToolsCallSearch tools/call search 全链路(同 /v1 内核)+ isError 语义。 func TestMCPToolsCallSearch(t *testing.T) { e := newTestEnv(t) e.sched.autoComplete = true e.sched.doneEnv = func(rid string) json.RawMessage { b, _ := json.Marshal(searchOKEnvelope(rid)) return b } rec := postMCP(e, `{"jsonrpc":"2.0","id":3,"method":"tools/call", "params":{"name":"search","arguments":{"query":"MCP测试","region":"domestic","max_results":5}}}`) if rec.Code != http.StatusOK { t.Fatalf("want 200: %d %s", rec.Code, rec.Body.String()) } var resp struct { ID json.RawMessage `json:"id"` Result struct { Content []struct { Type string `json:"type"` Text string `json:"text"` } `json:"content"` IsError bool `json:"isError"` Error any `json:"error"` } `json:"result"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatal(err) } if string(resp.ID) != "3" || resp.Result.IsError { t.Fatalf("tools/call 成功语义不符: isError=%v %s", resp.Result.IsError, rec.Body.String()) } if len(resp.Result.Content) != 1 || resp.Result.Content[0].Type != "text" { t.Fatalf("content 形状不符: %v", resp.Result.Content) } // text 内容是统一信封 JSON(golden 字段) var env map[string]any if err := json.Unmarshal([]byte(resp.Result.Content[0].Text), &env); err != nil { t.Fatalf("content.text 非信封 JSON: %s", resp.Result.Content[0].Text) } if env["kind"] != "search" || env["ok"] != true { t.Fatalf("信封 kind/ok 不符: %v", env) } if _, ok := env["results"].([]any); !ok { t.Fatal("results 应为 []") } } // TestMCPToolsCallErrors 错误路径:isError=true 且 error.code 与 HTTP 面一致。 func TestMCPToolsCallErrors(t *testing.T) { e := newTestEnv(t) // 401(无效 key):认证中间件在 MCP 面直接返回统一信封(非 JSON-RPC 包装), // 断言两面 code 一致(mcp-usage §3:error.code 与 HTTP 面一致) req := httptest.NewRequest(http.MethodPost, "/mcp", strReader( `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"query":"x","region":"domestic"}}}`)) req.Header.Set("X-Service-Token", "bs_invalidinvalid00") rec := httptest.NewRecorder() e.srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("无效 key 应 401: %d %s", rec.Code, rec.Body.String()) } var env struct { Error *struct { Code string `json:"code"` } `json:"error"` } _ = json.Unmarshal(rec.Body.Bytes(), &env) if env.Error == nil || env.Error.Code != "unauthorized" { t.Fatalf("MCP 401 信封 code 不符: %s", rec.Body.String()) } // 403(SSRF) rec2 := postMCP(e, `{"jsonrpc":"2.0","id":2,"method":"tools/call", "params":{"name":"read","arguments":{"url":"http://10.0.0.1/x"}}}`) assertMCPError(t, rec2, "denied") // 402(配额尽) cid, _ := e.db.CreateConsumer("mcp-quota", "") key, _, _ := e.verifier.Issue(cid, "mcp-quota-key", nil, 60, 1, 1000, 2, nil) e.sched.autoComplete = true e.sched.doneEnv = func(rid string) json.RawMessage { b, _ := json.Marshal(searchOKEnvelope(rid)) return b } _ = postMCPAs(e, key, `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search","arguments":{"query":"one","region":"domestic"}}}`) rec3 := postMCPAs(e, key, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"search","arguments":{"query":"two","region":"domestic"}}}`) assertMCPError(t, rec3, "quota") // 未知 method → -32601 rec4 := postMCP(e, `{"jsonrpc":"2.0","id":5,"method":"resources/list"}`) var er struct { Error *struct { Code int `json:"code"` } `json:"error"` } _ = json.Unmarshal(rec4.Body.Bytes(), &er) if er.Error == nil || er.Error.Code != errMethodNotFound { t.Fatalf("未知 method 应 -32601: %s", rec4.Body.String()) } // tools/call 未知工具 → -32602 rec5 := postMCP(e, `{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"browse","arguments":{}}}`) er = struct { Error *struct { Code int `json:"code"` } `json:"error"` }{} _ = json.Unmarshal(rec5.Body.Bytes(), &er) if er.Error == nil || er.Error.Code != errInvalidParams { t.Fatalf("未知工具应 -32602: %s", rec5.Body.String()) } // 非法 JSON → -32700(错误用 errParse 值断言) rec6 := postMCP(e, `{not-json`) er = struct { Error *struct { Code int `json:"code"` } `json:"error"` }{} _ = json.Unmarshal(rec6.Body.Bytes(), &er) if er.Error == nil || er.Error.Code != -32700 { t.Fatalf("非法 JSON 应 -32700: %s", rec6.Body.String()) } // 非法 jsonrpc → -32600 rec7 := postMCP(e, `{"jsonrpc":"1.0","id":7,"method":"tools/list"}`) er = struct { Error *struct { Code int `json:"code"` } `json:"error"` }{} _ = json.Unmarshal(rec7.Body.Bytes(), &er) if er.Error == nil || er.Error.Code != errInvalidRequest { t.Fatalf("非法 jsonrpc 应 -32600: %s", rec7.Body.String()) } } // postMCPAs 指定 key。 func postMCPAs(e *testEnv, key, body string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, "/mcp", strReader(body)) req.Header.Set("X-Service-Token", key) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() e.srv.Handler().ServeHTTP(rec, req) return rec } // assertMCPError 断言 tool result isError=true 且 error.code 匹配。 func assertMCPError(t *testing.T, rec *httptest.ResponseRecorder, code string) { t.Helper() var resp struct { Result struct { IsError bool `json:"isError"` Error *struct { Code string `json:"code"` } `json:"error"` Content []struct { Text string `json:"text"` } `json:"content"` } `json:"result"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("MCP 响应非 JSON: %s", rec.Body.String()) } if !resp.Result.IsError { t.Fatalf("isError 应 true(%s): %s", code, rec.Body.String()) } if resp.Result.Error == nil || resp.Result.Error.Code != code { t.Fatalf("error.code 应 %s: %s", code, rec.Body.String()) } // content[0].text 是信封 JSON 且 code 一致(mcp-usage §3 双面一致) if len(resp.Result.Content) == 1 { var env struct { Error *struct { Code string `json:"code"` } `json:"error"` } if json.Unmarshal([]byte(resp.Result.Content[0].Text), &env) == nil && env.Error != nil && env.Error.Code != code { t.Fatalf("content 内信封 code=%s 与 isError 面 %s 不一致", env.Error.Code, code) } } } // TestMCPContentType 协议头校验:Content-Type 非 application/json 拒绝。 func TestMCPContentType(t *testing.T) { e := newTestEnv(t) req := httptest.NewRequest(http.MethodPost, "/mcp", strReader(`{"jsonrpc":"2.0","id":1,"method":"ping"}`)) req.Header.Set("X-Service-Token", e.plainKey) req.Header.Set("Content-Type", "text/plain") rec := httptest.NewRecorder() e.srv.Handler().ServeHTTP(rec, req) var resp struct { Result *struct { Code int `json:"code"` } `json:"result"` Error *struct { Code int `json:"code"` } `json:"error"` } _ = json.Unmarshal(rec.Body.Bytes(), &resp) // 协议错误统一走 error 槽(-32600) if resp.Error == nil || resp.Error.Code != errInvalidRequest { t.Fatalf("非 JSON Content-Type 应 -32600: %s", rec.Body.String()) } // 缺省 Content-Type 容忍(带 MCP-Protocol-Version 头也不报错) req2 := httptest.NewRequest(http.MethodPost, "/mcp", strReader(`{"jsonrpc":"2.0","id":1,"method":"ping"}`)) req2.Header.Set("X-Service-Token", e.plainKey) req2.Header.Set("MCP-Protocol-Version", "2026-07-28") rec2 := httptest.NewRecorder() e.srv.Handler().ServeHTTP(rec2, req2) if strings.Contains(rec2.Body.String(), `"error"`) { t.Fatalf("ping 应成功: %s", rec2.Body.String()) } }