Files
wehub-resource-sync e071084ebe
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:40:33 +08:00

448 lines
12 KiB
Go

package mcp
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"go-micro.dev/v6/auth"
"github.com/gorilla/websocket"
)
// wsDialer creates a WebSocket connection to the given test server URL.
func wsDialer(t *testing.T, url string, headers http.Header) *websocket.Conn {
t.Helper()
wsURL := "ws" + strings.TrimPrefix(url, "http")
conn, _, err := websocket.DefaultDialer.Dial(wsURL, headers)
if err != nil {
t.Fatalf("WebSocket dial failed: %v", err)
}
t.Cleanup(func() { conn.Close() })
return conn
}
// sendJSONRPC sends a JSON-RPC request and reads the response.
func sendJSONRPC(t *testing.T, conn *websocket.Conn, method string, id interface{}, params interface{}) JSONRPCResponse {
t.Helper()
raw, _ := json.Marshal(params)
req := JSONRPCRequest{
JSONRPC: "2.0",
ID: id,
Method: method,
Params: raw,
}
if err := conn.WriteJSON(req); err != nil {
t.Fatalf("WriteJSON failed: %v", err)
}
var resp JSONRPCResponse
if err := conn.ReadJSON(&resp); err != nil {
t.Fatalf("ReadJSON failed: %v", err)
}
return resp
}
func newWSTestServer(t *testing.T, opts Options) (*Server, *httptest.Server) {
t.Helper()
s := newTestServer(opts)
ws := NewWebSocketTransport(s)
mux := http.NewServeMux()
mux.Handle("/mcp/ws", ws)
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
return s, ts
}
func TestWebSocket_Initialize(t *testing.T) {
_, ts := newWSTestServer(t, Options{})
conn := wsDialer(t, ts.URL+"/mcp/ws", nil)
resp := sendJSONRPC(t, conn, "initialize", 1, nil)
if resp.Error != nil {
t.Fatalf("unexpected error: %v", resp.Error)
}
result, ok := resp.Result.(map[string]interface{})
if !ok {
t.Fatal("expected map result")
}
if result["protocolVersion"] != "2024-11-05" {
t.Errorf("protocolVersion = %v, want 2024-11-05", result["protocolVersion"])
}
}
func TestWebSocket_ToolsList(t *testing.T) {
s, ts := newWSTestServer(t, Options{})
s.tools["svc.Echo"] = &Tool{
Name: "svc.Echo",
Description: "Echo a message",
InputSchema: map[string]interface{}{"type": "object"},
Service: "svc",
Endpoint: "Echo",
}
conn := wsDialer(t, ts.URL+"/mcp/ws", nil)
resp := sendJSONRPC(t, conn, "tools/list", 1, nil)
if resp.Error != nil {
t.Fatalf("unexpected error: %v", resp.Error)
}
result, _ := resp.Result.(map[string]interface{})
tools, _ := result["tools"].([]interface{})
if len(tools) != 1 {
t.Fatalf("expected 1 tool, got %d", len(tools))
}
}
func TestWebSocket_ToolsCall_NoAuth(t *testing.T) {
s, ts := newWSTestServer(t, Options{})
s.tools["svc.Echo"] = &Tool{
Name: "svc.Echo",
Service: "svc",
Endpoint: "Echo",
}
conn := wsDialer(t, ts.URL+"/mcp/ws", nil)
resp := sendJSONRPC(t, conn, "tools/call", 1, map[string]interface{}{
"name": "svc.Echo",
"arguments": map[string]interface{}{"msg": "hi"},
})
// No auth required → the tool runs; the RPC fails (no backend), which the
// MCP spec surfaces as an isError result, not a JSON-RPC protocol error.
if resp.Error != nil {
t.Fatalf("expected no protocol error, got %+v", resp.Error)
}
if !isToolError(resp.Result) {
t.Fatalf("expected isError tool result, got %+v", resp.Result)
}
}
func TestWebSocket_ToolsCall_AuthRequired(t *testing.T) {
ma := &mockAuth{
accounts: map[string]*auth.Account{
"valid-token": {ID: "user-1", Scopes: []string{"blog:write"}},
},
}
s, ts := newWSTestServer(t, Options{Auth: ma})
s.tools["svc.Do"] = &Tool{
Name: "svc.Do",
Service: "svc",
Endpoint: "Do",
Scopes: []string{"blog:write"},
}
t.Run("missing token", func(t *testing.T) {
conn := wsDialer(t, ts.URL+"/mcp/ws", nil)
resp := sendJSONRPC(t, conn, "tools/call", 1, map[string]interface{}{
"name": "svc.Do",
"arguments": map[string]interface{}{},
})
if resp.Error == nil || resp.Error.Message != "Unauthorized" {
t.Errorf("expected Unauthorized, got %+v", resp.Error)
}
})
t.Run("invalid token", func(t *testing.T) {
conn := wsDialer(t, ts.URL+"/mcp/ws", nil)
resp := sendJSONRPC(t, conn, "tools/call", 1, map[string]interface{}{
"name": "svc.Do",
"arguments": map[string]interface{}{},
"_token": "bad-token",
})
if resp.Error == nil || resp.Error.Message != "Unauthorized" {
t.Errorf("expected Unauthorized, got %+v", resp.Error)
}
})
t.Run("valid token via param", func(t *testing.T) {
conn := wsDialer(t, ts.URL+"/mcp/ws", nil)
resp := sendJSONRPC(t, conn, "tools/call", 1, map[string]interface{}{
"name": "svc.Do",
"arguments": map[string]interface{}{},
"_token": "valid-token",
})
// Auth passes → the tool runs; RPC fails (no backend) → isError result,
// not a JSON-RPC protocol error (which would mean auth failed).
if resp.Error != nil {
t.Fatalf("expected no protocol error (auth passed), got %+v", resp.Error)
}
if !isToolError(resp.Result) {
t.Fatalf("expected isError tool result, got %+v", resp.Result)
}
})
t.Run("valid token via header", func(t *testing.T) {
headers := http.Header{}
headers.Set("Authorization", "Bearer valid-token")
conn := wsDialer(t, ts.URL+"/mcp/ws", headers)
resp := sendJSONRPC(t, conn, "tools/call", 1, map[string]interface{}{
"name": "svc.Do",
"arguments": map[string]interface{}{},
})
// Auth passes via connection-level header → tool runs; RPC fails (no
// backend) → isError result, not a JSON-RPC protocol error.
if resp.Error != nil {
t.Fatalf("expected no protocol error (auth passed), got %+v", resp.Error)
}
if !isToolError(resp.Result) {
t.Fatalf("expected isError tool result, got %+v", resp.Result)
}
})
}
func TestWebSocket_ToolsCall_InsufficientScopes(t *testing.T) {
ma := &mockAuth{
accounts: map[string]*auth.Account{
"readonly": {ID: "user-2", Scopes: []string{"blog:read"}},
},
}
s, ts := newWSTestServer(t, Options{Auth: ma})
s.tools["svc.Do"] = &Tool{
Name: "svc.Do",
Service: "svc",
Endpoint: "Do",
Scopes: []string{"blog:write"},
}
conn := wsDialer(t, ts.URL+"/mcp/ws", nil)
resp := sendJSONRPC(t, conn, "tools/call", 1, map[string]interface{}{
"name": "svc.Do",
"arguments": map[string]interface{}{},
"_token": "readonly",
})
if resp.Error == nil || resp.Error.Message != "Forbidden" {
t.Errorf("expected Forbidden, got %+v", resp.Error)
}
}
func TestWebSocket_ToolsCall_Audit(t *testing.T) {
var mu sync.Mutex
var records []AuditRecord
s, ts := newWSTestServer(t, Options{
AuditFunc: func(r AuditRecord) {
mu.Lock()
records = append(records, r)
mu.Unlock()
},
})
s.tools["svc.Do"] = &Tool{
Name: "svc.Do",
Service: "svc",
Endpoint: "Do",
}
conn := wsDialer(t, ts.URL+"/mcp/ws", nil)
sendJSONRPC(t, conn, "tools/call", 1, map[string]interface{}{
"name": "svc.Do",
"arguments": map[string]interface{}{},
})
mu.Lock()
defer mu.Unlock()
if len(records) == 0 {
t.Fatal("expected audit record")
}
r := records[len(records)-1]
if r.Tool != "svc.Do" {
t.Errorf("audit Tool = %q, want %q", r.Tool, "svc.Do")
}
if r.TraceID == "" {
t.Error("audit TraceID is empty")
}
}
func TestWebSocket_RateLimit(t *testing.T) {
s, ts := newWSTestServer(t, Options{
RateLimit: &RateLimitConfig{RequestsPerSecond: 1, Burst: 1},
})
s.tools["svc.Do"] = &Tool{
Name: "svc.Do",
Service: "svc",
Endpoint: "Do",
}
s.limiters["svc.Do"] = newRateLimiter(1, 1)
conn := wsDialer(t, ts.URL+"/mcp/ws", nil)
params := map[string]interface{}{
"name": "svc.Do",
"arguments": map[string]interface{}{},
}
// First request passes rate limit (RPC may fail, that's ok)
resp1 := sendJSONRPC(t, conn, "tools/call", 1, params)
if resp1.Error != nil && resp1.Error.Message == "Rate limit exceeded" {
t.Error("first request should not be rate limited")
}
// Second request should be rate limited
resp2 := sendJSONRPC(t, conn, "tools/call", 2, params)
if resp2.Error == nil || resp2.Error.Message != "Rate limit exceeded" {
t.Errorf("expected rate limit error, got %+v", resp2.Error)
}
}
func TestWebSocket_MethodNotFound(t *testing.T) {
_, ts := newWSTestServer(t, Options{})
conn := wsDialer(t, ts.URL+"/mcp/ws", nil)
resp := sendJSONRPC(t, conn, "nonexistent/method", 1, nil)
if resp.Error == nil || resp.Error.Code != MethodNotFound {
t.Errorf("expected MethodNotFound, got %+v", resp.Error)
}
}
func TestWebSocket_ToolNotFound(t *testing.T) {
_, ts := newWSTestServer(t, Options{})
conn := wsDialer(t, ts.URL+"/mcp/ws", nil)
resp := sendJSONRPC(t, conn, "tools/call", 1, map[string]interface{}{
"name": "nonexistent.Tool",
"arguments": map[string]interface{}{},
})
if resp.Error == nil || resp.Error.Message != "Tool not found" {
t.Errorf("expected Tool not found, got %+v", resp.Error)
}
}
func TestWebSocket_MultipleConcurrentRequests(t *testing.T) {
s, ts := newWSTestServer(t, Options{})
s.tools["svc.Echo"] = &Tool{
Name: "svc.Echo",
Service: "svc",
Endpoint: "Echo",
}
conn := wsDialer(t, ts.URL+"/mcp/ws", nil)
// Send multiple requests sequentially (gorilla client doesn't allow
// concurrent writes), but the server handles them concurrently.
const n = 5
for i := 0; i < n; i++ {
raw, _ := json.Marshal(map[string]interface{}{
"name": "svc.Echo",
"arguments": map[string]interface{}{},
})
req := JSONRPCRequest{
JSONRPC: "2.0",
ID: i + 1,
Method: "tools/call",
Params: raw,
}
if err := conn.WriteJSON(req); err != nil {
t.Fatalf("WriteJSON %d failed: %v", i, err)
}
}
// Read all responses (order may vary due to concurrent server handling)
responses := make([]JSONRPCResponse, n)
for i := 0; i < n; i++ {
if err := conn.ReadJSON(&responses[i]); err != nil {
t.Fatalf("ReadJSON failed at %d: %v", i, err)
}
}
for i, resp := range responses {
if resp.JSONRPC != "2.0" {
t.Errorf("response %d: jsonrpc = %q, want '2.0'", i, resp.JSONRPC)
}
}
}
func TestWebSocket_MultipleConnections(t *testing.T) {
s, ts := newWSTestServer(t, Options{})
s.tools["svc.Echo"] = &Tool{
Name: "svc.Echo",
Description: "Echo",
InputSchema: map[string]interface{}{"type": "object"},
Service: "svc",
Endpoint: "Echo",
}
// Connect multiple clients simultaneously
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
conn := wsDialer(t, ts.URL+"/mcp/ws", nil)
resp := sendJSONRPC(t, conn, "tools/list", idx+1, nil)
if resp.Error != nil {
t.Errorf("client %d: unexpected error: %v", idx, resp.Error)
}
}(i)
}
wg.Wait()
}
func TestWebSocket_InvalidJSON(t *testing.T) {
_, ts := newWSTestServer(t, Options{})
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/mcp/ws"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
// Send invalid JSON
conn.WriteMessage(websocket.TextMessage, []byte("not json"))
var resp JSONRPCResponse
if err := conn.ReadJSON(&resp); err != nil {
t.Fatalf("ReadJSON failed: %v", err)
}
if resp.Error == nil || resp.Error.Code != ParseError {
t.Errorf("expected ParseError, got %+v", resp.Error)
}
}
func TestWebSocket_InvalidJSONRPCVersion(t *testing.T) {
_, ts := newWSTestServer(t, Options{})
conn := wsDialer(t, ts.URL+"/mcp/ws", nil)
req := map[string]interface{}{
"jsonrpc": "1.0",
"id": 1,
"method": "initialize",
}
conn.WriteJSON(req)
var resp JSONRPCResponse
conn.ReadJSON(&resp)
if resp.Error == nil || resp.Error.Code != InvalidRequest {
t.Errorf("expected InvalidRequest, got %+v", resp.Error)
}
}
func TestWebSocket_ConnectionPersistence(t *testing.T) {
_, ts := newWSTestServer(t, Options{})
conn := wsDialer(t, ts.URL+"/mcp/ws", nil)
// Send multiple sequential requests on the same connection
for i := 0; i < 3; i++ {
resp := sendJSONRPC(t, conn, "initialize", i+1, nil)
if resp.Error != nil {
t.Errorf("request %d: unexpected error: %v", i, resp.Error)
}
}
// Connection should still be alive after a short delay
time.Sleep(50 * time.Millisecond)
resp := sendJSONRPC(t, conn, "initialize", 99, nil)
if resp.Error != nil {
t.Errorf("request after delay: unexpected error: %v", resp.Error)
}
}