Files
ai-gateway-go/internal/workbench/mcp_client_test.go
T
superidou 5759c1862e AI Gateway Go 0.10.0 源码快照 + 旗舰版需求规划报告
M0-M7 已完成:核心网关(身份/RBAC/TOTP/OIDC/SAML/Provider/配额/路由/内容策略/审计/定价)+ 资源市场(MCP/Skills/数字员工)。
含 22 个 PostgreSQL 迁移、管理端/门户端前端源码、OpenAPI 契约、部署 compose。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 11:45:54 +08:00

238 lines
8.1 KiB
Go

package workbench
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// minimalMCP is a scriptable JSON-RPC MCP server for exercising the client.
type minimalMCP struct {
t *testing.T
server *httptest.Server
tools []MCPTool
initCount int32
listCount int32
callCount int32
lastArgs map[string]any
lastTool string
versioned bool
}
func newMinimalMCP(t *testing.T) *minimalMCP {
m := &minimalMCP{t: t, tools: []MCPTool{
{Name: "lookup", Description: "look something up", InputSchema: json.RawMessage(`{"type":"object","properties":{"q":{"type":"string"}},"required":["q"]}`)},
{Name: "add", Description: "add two numbers", InputSchema: json.RawMessage(`{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"]}`)},
}}
m.server = httptest.NewServer(http.HandlerFunc(m.serve))
t.Cleanup(m.server.Close)
return m
}
func (m *minimalMCP) endpoint() string { return m.server.URL + "/mcp" }
func (m *minimalMCP) serve(w http.ResponseWriter, r *http.Request) {
var req mcpRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
switch req.Method {
case "initialize":
atomic.AddInt32(&m.initCount, 1)
params, _ := req.Params.(map[string]any)
clientInfo, _ := params["clientInfo"].(map[string]any)
if clientInfo["name"] == nil || clientInfo["name"] == "" {
m.t.Error("initialize missing clientInfo.name")
}
result := map[string]any{
"protocolVersion": "2025-06-18",
"capabilities": map[string]any{"tools": map[string]any{}},
"serverInfo": map[string]any{"name": "test-mcp", "version": "1.0.0"},
}
m.write(w, req.ID, result, false)
case "notifications/initialized":
w.WriteHeader(http.StatusAccepted)
case "tools/list":
atomic.AddInt32(&m.listCount, 1)
m.write(w, req.ID, map[string]any{"tools": m.tools}, false)
case "tools/call":
atomic.AddInt32(&m.callCount, 1)
params, _ := req.Params.(map[string]any)
name, _ := params["name"].(string)
args, _ := params["arguments"].(map[string]any)
m.lastArgs = args
m.lastTool = name
if name == "boom" {
m.write(w, req.ID, map[string]any{"content": []map[string]any{{"type": "text", "text": "failed intentionally"}}, "isError": true}, false)
return
}
if name == "nope" {
m.write(w, req.ID, nil, true)
return
}
m.write(w, req.ID, map[string]any{"content": []map[string]any{{"type": "text", "text": "result for " + name}}, "isError": false}, false)
case "tools/fail":
m.write(w, req.ID, nil, true)
default:
http.Error(w, "unknown method", http.StatusBadRequest)
}
}
func (m *minimalMCP) write(w http.ResponseWriter, id any, result any, isError bool) {
w.Header().Set("Content-Type", "application/json")
if isError {
json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": id, "error": map[string]any{"code": -32601, "message": "method not found"}})
return
}
json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": id, "result": result})
}
func testServer(code string) MCPServer {
return MCPServer{ID: "00000000-0000-0000-0000-000000000001", Code: code}
}
func TestMCPDiscoverTools(t *testing.T) {
mcp := newMinimalMCP(t)
client := NewMCPClient(true, time.Minute)
server := testServer("demo")
server.EndpointURL = mcp.endpoint()
tools, err := client.DiscoverTools(context.Background(), server, nil)
if err != nil {
t.Fatalf("discover failed: %v", err)
}
if len(tools) != 2 || tools[0].Name != "lookup" {
t.Fatalf("expected 2 tools starting with lookup, got %+v", tools)
}
if atomic.LoadInt32(&mcp.initCount) != 1 {
t.Fatalf("expected exactly one initialize handshake, got %d", mcp.initCount)
}
// Second call hits the tool cache, no new tools/list.
if _, err = client.DiscoverTools(context.Background(), server, nil); err != nil {
t.Fatalf("cached discover failed: %v", err)
}
if atomic.LoadInt32(&mcp.listCount) != 1 {
t.Fatalf("expected tools/list to run once, got %d", mcp.listCount)
}
}
func TestMCPCacheExpiryRediscover(t *testing.T) {
mcp := newMinimalMCP(t)
client := NewMCPClient(true, 50*time.Millisecond)
server := testServer("expire")
server.EndpointURL = mcp.endpoint()
if _, err := client.DiscoverTools(context.Background(), server, nil); err != nil {
t.Fatalf("discover failed: %v", err)
}
time.Sleep(80 * time.Millisecond)
if _, err := client.DiscoverTools(context.Background(), server, nil); err != nil {
t.Fatalf("rediscover failed: %v", err)
}
if atomic.LoadInt32(&mcp.listCount) != 2 {
t.Fatalf("expected tools/list to run twice after expiry, got %d", mcp.listCount)
}
}
func TestMCPCallTool(t *testing.T) {
mcp := newMinimalMCP(t)
client := NewMCPClient(true, time.Minute)
server := testServer("call")
server.EndpointURL = mcp.endpoint()
result, err := client.CallTool(context.Background(), server, nil, "lookup", map[string]any{"q": "hello"})
if err != nil {
t.Fatalf("call failed: %v", err)
}
if result.Content != "result for lookup" {
t.Fatalf("unexpected content %q", result.Content)
}
if mcp.lastArgs["q"] != "hello" {
t.Fatalf("arguments not forwarded: %v", mcp.lastArgs)
}
// The runtime exposes tools under the mcp__{code}__{tool} prefix; CallTool
// must strip it so the remote server sees the real tool name.
if mcp.lastTool != "lookup" {
t.Fatalf("tool name not sent as-is: %q", mcp.lastTool)
}
if _, err := client.CallTool(context.Background(), server, nil, "mcp__call__lookup", map[string]any{"q": "x"}); err != nil {
t.Fatalf("prefixed call failed: %v", err)
}
if mcp.lastTool != "lookup" {
t.Fatalf("prefixed tool name was not stripped: %q", mcp.lastTool)
}
}
func TestMCPCallToolIsError(t *testing.T) {
mcp := newMinimalMCP(t)
client := NewMCPClient(true, time.Minute)
server := testServer("err")
server.EndpointURL = mcp.endpoint()
if _, err := client.CallTool(context.Background(), server, nil, "boom", nil); err == nil {
t.Fatal("expected error for isError tool result")
}
}
func TestMCPJSONRPCError(t *testing.T) {
mcp := newMinimalMCP(t)
client := NewMCPClient(true, time.Minute)
server := testServer("rpc")
server.EndpointURL = mcp.endpoint()
if _, err := client.CallTool(context.Background(), server, nil, "nope", nil); err == nil || !strings.Contains(err.Error(), "method not found") {
t.Fatalf("expected JSON-RPC error surfaced, got %v", err)
}
}
func TestMCPToolNamePrefixRoundTrip(t *testing.T) {
prefixed := mcpToolName("github", "create-issue")
if prefixed != "mcp__github__create-issue" {
t.Fatalf("unexpected prefix %q", prefixed)
}
code, tool, ok := resolveMCPTool(prefixed)
if !ok || code != "github" || tool != "create-issue" {
t.Fatalf("resolve failed: %q %q %v", code, tool, ok)
}
if _, _, ok := resolveMCPTool("plain-name"); ok {
t.Fatal("non-prefixed name should not resolve as MCP tool")
}
}
func TestMCPSSESingleFrame(t *testing.T) {
handler := func(w http.ResponseWriter, r *http.Request) {
var req mcpRequest
_ = json.NewDecoder(r.Body).Decode(&req)
w.Header().Set("Content-Type", "text/event-stream")
switch req.Method {
case "initialize":
payload, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": map[string]any{"protocolVersion": "2025-06-18", "capabilities": map[string]any{}, "serverInfo": map[string]any{"name": "sse", "version": "1"}}})
w.Write([]byte("event: message\ndata: " + string(payload) + "\n\n"))
case "notifications/initialized":
w.WriteHeader(http.StatusAccepted)
case "tools/list":
payload, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": map[string]any{"tools": []MCPTool{{Name: "sse-tool", Description: "", InputSchema: json.RawMessage(`{}`)}}}})
w.Write([]byte("event: message\ndata: " + string(payload) + "\n\n"))
}
}
server := httptest.NewServer(http.HandlerFunc(handler))
defer server.Close()
client := NewMCPClient(true, time.Minute)
svr := testServer("sse")
svr.EndpointURL = server.URL
tools, err := client.DiscoverTools(context.Background(), svr, nil)
if err != nil {
t.Fatalf("SSE discover failed: %v", err)
}
if len(tools) != 1 || tools[0].Name != "sse-tool" {
t.Fatalf("unexpected SSE tools: %+v", tools)
}
}