单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。 Co-authored-by: Cursor <cursoragent@cursor.com>
320 lines
11 KiB
Go
320 lines
11 KiB
Go
// 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())
|
||
}
|
||
}
|