5759c1862e
M0-M7 已完成:核心网关(身份/RBAC/TOTP/OIDC/SAML/Provider/配额/路由/内容策略/审计/定价)+ 资源市场(MCP/Skills/数字员工)。 含 22 个 PostgreSQL 迁移、管理端/门户端前端源码、OpenAPI 契约、部署 compose。 Co-Authored-By: Claude <noreply@anthropic.com>
238 lines
8.1 KiB
Go
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)
|
|
}
|
|
}
|