onesvm-browser-server/server/internal/gateway/mcp_test.go
chii eb972dfa93 feat: 落地 browser-server 控制面并打通 mgr1 海外订阅
单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:05:12 +08:00

320 lines
11 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.

// 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())
}
}