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
569 lines
14 KiB
Go
569 lines
14 KiB
Go
package mcp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"go-micro.dev/v6/auth"
|
|
"go-micro.dev/v6/client"
|
|
"go-micro.dev/v6/registry"
|
|
)
|
|
|
|
// mockAuth implements auth.Auth for testing.
|
|
type mockAuth struct {
|
|
accounts map[string]*auth.Account // token -> account
|
|
}
|
|
|
|
func (m *mockAuth) Init(...auth.Option) {}
|
|
func (m *mockAuth) Options() auth.Options { return auth.Options{} }
|
|
func (m *mockAuth) Generate(string, ...auth.GenerateOption) (*auth.Account, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockAuth) Token(...auth.TokenOption) (*auth.Token, error) { return nil, nil }
|
|
func (m *mockAuth) String() string { return "mock" }
|
|
|
|
func (m *mockAuth) Inspect(token string) (*auth.Account, error) {
|
|
acc, ok := m.accounts[token]
|
|
if !ok {
|
|
return nil, auth.ErrInvalidToken
|
|
}
|
|
return acc, nil
|
|
}
|
|
|
|
// newTestServer creates a Server with pre-populated tools for testing.
|
|
func newTestServer(opts Options) *Server {
|
|
if opts.Logger == nil {
|
|
opts.Logger = testLogger()
|
|
}
|
|
if opts.Context == nil {
|
|
opts.Context = context.Background()
|
|
}
|
|
if opts.Client == nil {
|
|
opts.Client = client.DefaultClient
|
|
}
|
|
s := &Server{
|
|
opts: opts,
|
|
tools: make(map[string]*Tool),
|
|
limiters: make(map[string]*rateLimiter),
|
|
}
|
|
return s
|
|
}
|
|
|
|
// testLogger returns a silent logger for tests.
|
|
func testLogger() *log.Logger {
|
|
return log.New(nopWriter{}, "", 0)
|
|
}
|
|
|
|
type nopWriter struct{}
|
|
|
|
func (nopWriter) Write(p []byte) (int, error) { return len(p), nil }
|
|
|
|
// --- Tests ---
|
|
|
|
func TestHasScope(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
account []string
|
|
required []string
|
|
want bool
|
|
}{
|
|
{"match single", []string{"blog:write"}, []string{"blog:write"}, true},
|
|
{"match one of many", []string{"blog:read", "blog:write"}, []string{"blog:write"}, true},
|
|
{"no match", []string{"blog:read"}, []string{"blog:write"}, false},
|
|
{"empty required", []string{"blog:read"}, nil, false},
|
|
{"empty account", nil, []string{"blog:write"}, false},
|
|
{"case insensitive", []string{"Blog:Write"}, []string{"blog:write"}, true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := hasScope(tt.account, tt.required)
|
|
if got != tt.want {
|
|
t.Errorf("hasScope(%v, %v) = %v, want %v", tt.account, tt.required, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestToolScopesFromMetadata(t *testing.T) {
|
|
// Create a mock registry with endpoints that have scope metadata
|
|
reg := registry.NewMemoryRegistry()
|
|
svc := ®istry.Service{
|
|
Name: "blog",
|
|
Nodes: []*registry.Node{{
|
|
Id: "blog-1",
|
|
Address: "localhost:9090",
|
|
}},
|
|
Endpoints: []*registry.Endpoint{
|
|
{
|
|
Name: "Blog.Create",
|
|
Metadata: map[string]string{
|
|
"description": "Create a blog post",
|
|
"scopes": "blog:write,blog:admin",
|
|
},
|
|
},
|
|
{
|
|
Name: "Blog.Read",
|
|
Metadata: map[string]string{
|
|
"description": "Read a blog post",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
if err := reg.Register(svc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
s := newTestServer(Options{Registry: reg})
|
|
if err := s.discoverServices(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Check that scopes are populated
|
|
createTool := s.tools["blog.Blog.Create"]
|
|
if createTool == nil {
|
|
t.Fatal("expected tool blog.Blog.Create")
|
|
}
|
|
if len(createTool.Scopes) != 2 || createTool.Scopes[0] != "blog:write" || createTool.Scopes[1] != "blog:admin" {
|
|
t.Errorf("unexpected scopes: %v", createTool.Scopes)
|
|
}
|
|
|
|
readTool := s.tools["blog.Blog.Read"]
|
|
if readTool == nil {
|
|
t.Fatal("expected tool blog.Blog.Read")
|
|
}
|
|
if len(readTool.Scopes) != 0 {
|
|
t.Errorf("expected no scopes for read, got: %v", readTool.Scopes)
|
|
}
|
|
}
|
|
|
|
func TestHandleCallTool_AuthRequired(t *testing.T) {
|
|
ma := &mockAuth{
|
|
accounts: map[string]*auth.Account{
|
|
"valid-token": {ID: "user-1", Scopes: []string{"blog:write"}},
|
|
"readonly": {ID: "user-2", Scopes: []string{"blog:read"}},
|
|
},
|
|
}
|
|
|
|
s := newTestServer(Options{Auth: ma})
|
|
s.tools["blog.Blog.Create"] = &Tool{
|
|
Name: "blog.Blog.Create",
|
|
Service: "blog",
|
|
Endpoint: "Blog.Create",
|
|
Scopes: []string{"blog:write"},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
token string
|
|
wantStatus int
|
|
}{
|
|
{"no token", "", http.StatusUnauthorized},
|
|
{"invalid token", "bad-token", http.StatusUnauthorized},
|
|
{"valid token with scope", "valid-token", http.StatusInternalServerError}, // RPC will fail (no backend), but auth passes
|
|
{"valid token without scope", "readonly", http.StatusForbidden},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"tool": "blog.Blog.Create",
|
|
"input": map[string]interface{}{"title": "hello"},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp/call", bytes.NewReader(body))
|
|
if tt.token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+tt.token)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
s.handleCallTool(rec, req)
|
|
|
|
if rec.Code != tt.wantStatus {
|
|
t.Errorf("status = %d, want %d, body: %s", rec.Code, tt.wantStatus, rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandleCallTool_TraceID(t *testing.T) {
|
|
// Without Auth, tool calls should still generate trace IDs.
|
|
s := newTestServer(Options{})
|
|
s.tools["svc.Echo"] = &Tool{
|
|
Name: "svc.Echo",
|
|
Service: "svc",
|
|
Endpoint: "Echo",
|
|
}
|
|
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"tool": "svc.Echo",
|
|
"input": map[string]interface{}{},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp/call", bytes.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
s.handleCallTool(rec, req)
|
|
|
|
// Even though the RPC fails (no backend), the trace ID header should be absent
|
|
// only when the call didn't reach the RPC stage; but in this no-auth case it
|
|
// should reach the RPC call and fail. Check we get a response.
|
|
traceID := rec.Header().Get(TraceIDKey)
|
|
// The RPC call will fail but the error path doesn't set the header.
|
|
// For a successful call, the trace ID is set. Either way the audit should fire.
|
|
_ = traceID // trace ID may or may not be in error response header
|
|
}
|
|
|
|
func TestHandleCallTool_AuditFunc(t *testing.T) {
|
|
var mu sync.Mutex
|
|
var records []AuditRecord
|
|
|
|
auditFn := func(r AuditRecord) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
records = append(records, r)
|
|
}
|
|
|
|
ma := &mockAuth{
|
|
accounts: map[string]*auth.Account{
|
|
"tok": {ID: "u1", Scopes: []string{"write"}},
|
|
},
|
|
}
|
|
|
|
s := newTestServer(Options{Auth: ma, AuditFunc: auditFn})
|
|
s.tools["svc.Do"] = &Tool{
|
|
Name: "svc.Do",
|
|
Service: "svc",
|
|
Endpoint: "Do",
|
|
Scopes: []string{"write"},
|
|
}
|
|
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"tool": "svc.Do",
|
|
"input": map[string]interface{}{},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp/call", bytes.NewReader(body))
|
|
req.Header.Set("Authorization", "Bearer tok")
|
|
rec := httptest.NewRecorder()
|
|
s.handleCallTool(rec, req)
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
if len(records) == 0 {
|
|
t.Fatal("expected at least one audit record")
|
|
}
|
|
r := records[len(records)-1]
|
|
if r.AccountID != "u1" {
|
|
t.Errorf("audit AccountID = %q, want %q", r.AccountID, "u1")
|
|
}
|
|
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")
|
|
}
|
|
if !r.Allowed {
|
|
t.Error("expected audit record Allowed = true")
|
|
}
|
|
}
|
|
|
|
func TestHandleCallTool_AuditDenied(t *testing.T) {
|
|
var mu sync.Mutex
|
|
var records []AuditRecord
|
|
|
|
auditFn := func(r AuditRecord) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
records = append(records, r)
|
|
}
|
|
|
|
ma := &mockAuth{
|
|
accounts: map[string]*auth.Account{
|
|
"tok": {ID: "u1", Scopes: []string{"blog:read"}},
|
|
},
|
|
}
|
|
|
|
s := newTestServer(Options{Auth: ma, AuditFunc: auditFn})
|
|
s.tools["svc.Do"] = &Tool{
|
|
Name: "svc.Do",
|
|
Service: "svc",
|
|
Endpoint: "Do",
|
|
Scopes: []string{"blog:write"},
|
|
}
|
|
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"tool": "svc.Do",
|
|
"input": map[string]interface{}{},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp/call", bytes.NewReader(body))
|
|
req.Header.Set("Authorization", "Bearer tok")
|
|
rec := httptest.NewRecorder()
|
|
s.handleCallTool(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusForbidden)
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
if len(records) == 0 {
|
|
t.Fatal("expected audit record for denied call")
|
|
}
|
|
r := records[0]
|
|
if r.Allowed {
|
|
t.Error("expected Allowed = false")
|
|
}
|
|
if r.DeniedReason != "insufficient scopes" {
|
|
t.Errorf("DeniedReason = %q, want %q", r.DeniedReason, "insufficient scopes")
|
|
}
|
|
}
|
|
|
|
func TestRateLimiter(t *testing.T) {
|
|
rl := newRateLimiter(10, 2)
|
|
|
|
// First two should be allowed (burst)
|
|
if !rl.Allow() {
|
|
t.Error("first call should be allowed")
|
|
}
|
|
if !rl.Allow() {
|
|
t.Error("second call should be allowed (burst)")
|
|
}
|
|
|
|
// Third should be denied (burst exhausted, no time to refill)
|
|
if rl.Allow() {
|
|
t.Error("third call should be denied (burst exhausted)")
|
|
}
|
|
|
|
// Wait for refill
|
|
time.Sleep(150 * time.Millisecond)
|
|
|
|
// Should be allowed again
|
|
if !rl.Allow() {
|
|
t.Error("call after refill should be allowed")
|
|
}
|
|
}
|
|
|
|
func TestHandleCallTool_RateLimit(t *testing.T) {
|
|
var mu sync.Mutex
|
|
var records []AuditRecord
|
|
|
|
s := newTestServer(Options{
|
|
RateLimit: &RateLimitConfig{RequestsPerSecond: 1, Burst: 1},
|
|
AuditFunc: func(r AuditRecord) {
|
|
mu.Lock()
|
|
records = append(records, r)
|
|
mu.Unlock()
|
|
},
|
|
})
|
|
s.tools["svc.Do"] = &Tool{
|
|
Name: "svc.Do",
|
|
Service: "svc",
|
|
Endpoint: "Do",
|
|
}
|
|
s.limiters["svc.Do"] = newRateLimiter(1, 1)
|
|
|
|
makeReq := func() int {
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"tool": "svc.Do",
|
|
"input": map[string]interface{}{},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp/call", bytes.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
s.handleCallTool(rec, req)
|
|
return rec.Code
|
|
}
|
|
|
|
// First request should pass rate limit (but RPC may fail — that's ok)
|
|
code1 := makeReq()
|
|
if code1 == http.StatusTooManyRequests {
|
|
t.Error("first request should not be rate limited")
|
|
}
|
|
|
|
// Second request should be rate limited
|
|
code2 := makeReq()
|
|
if code2 != http.StatusTooManyRequests {
|
|
t.Errorf("second request status = %d, want %d", code2, http.StatusTooManyRequests)
|
|
}
|
|
|
|
// Check audit records include rate limit denial
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
found := false
|
|
for _, r := range records {
|
|
if r.DeniedReason == "rate limited" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected audit record with DeniedReason = 'rate limited'")
|
|
}
|
|
}
|
|
|
|
func TestHandleCallTool_NoAuth_NoScope(t *testing.T) {
|
|
// Without Auth configured, tools without scopes should be accessible
|
|
s := newTestServer(Options{})
|
|
s.tools["svc.Echo"] = &Tool{
|
|
Name: "svc.Echo",
|
|
Service: "svc",
|
|
Endpoint: "Echo",
|
|
}
|
|
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"tool": "svc.Echo",
|
|
"input": map[string]interface{}{"msg": "hi"},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp/call", bytes.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
s.handleCallTool(rec, req)
|
|
|
|
// Should not be 401 or 403 (RPC failure is expected since no backend)
|
|
if rec.Code == http.StatusUnauthorized || rec.Code == http.StatusForbidden {
|
|
t.Errorf("unexpected auth error: %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestToolScopesInJSON(t *testing.T) {
|
|
tool := &Tool{
|
|
Name: "blog.Blog.Create",
|
|
Description: "Create a blog post",
|
|
InputSchema: map[string]interface{}{"type": "object"},
|
|
Scopes: []string{"blog:write", "blog:admin"},
|
|
}
|
|
|
|
data, err := json.Marshal(tool)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var m map[string]interface{}
|
|
if err := json.Unmarshal(data, &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
scopes, ok := m["scopes"].([]interface{})
|
|
if !ok {
|
|
t.Fatal("expected scopes in JSON output")
|
|
}
|
|
if len(scopes) != 2 {
|
|
t.Errorf("expected 2 scopes, got %d", len(scopes))
|
|
}
|
|
}
|
|
|
|
func TestToolNoScopesOmittedInJSON(t *testing.T) {
|
|
tool := &Tool{
|
|
Name: "blog.Blog.Read",
|
|
Description: "Read a blog post",
|
|
InputSchema: map[string]interface{}{"type": "object"},
|
|
}
|
|
|
|
data, err := json.Marshal(tool)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var m map[string]interface{}
|
|
if err := json.Unmarshal(data, &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, ok := m["scopes"]; ok {
|
|
t.Error("expected scopes to be omitted when empty")
|
|
}
|
|
}
|
|
|
|
func TestDiscoverServices_RateLimiters(t *testing.T) {
|
|
reg := registry.NewMemoryRegistry()
|
|
svc := ®istry.Service{
|
|
Name: "blog",
|
|
Nodes: []*registry.Node{{
|
|
Id: "blog-1",
|
|
Address: "localhost:9090",
|
|
}},
|
|
Endpoints: []*registry.Endpoint{
|
|
{Name: "Blog.Create"},
|
|
{Name: "Blog.Read"},
|
|
},
|
|
}
|
|
if err := reg.Register(svc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
s := newTestServer(Options{
|
|
Registry: reg,
|
|
RateLimit: &RateLimitConfig{RequestsPerSecond: 10, Burst: 5},
|
|
})
|
|
if err := s.discoverServices(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if len(s.limiters) != len(s.tools) {
|
|
t.Errorf("expected %d limiters (one per tool), got %d", len(s.tools), len(s.limiters))
|
|
}
|
|
for name := range s.tools {
|
|
if _, ok := s.limiters[name]; !ok {
|
|
t.Errorf("missing limiter for tool %s", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestScopesFromGatewayOptions(t *testing.T) {
|
|
reg := registry.NewMemoryRegistry()
|
|
svc := ®istry.Service{
|
|
Name: "blog",
|
|
Nodes: []*registry.Node{{
|
|
Id: "blog-1",
|
|
Address: "localhost:9090",
|
|
}},
|
|
Endpoints: []*registry.Endpoint{
|
|
{
|
|
Name: "Blog.Create",
|
|
Metadata: map[string]string{
|
|
"scopes": "blog:write",
|
|
},
|
|
},
|
|
{
|
|
Name: "Blog.Delete",
|
|
},
|
|
},
|
|
}
|
|
if err := reg.Register(svc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Gateway-level Scopes override service-level metadata scopes
|
|
s := newTestServer(Options{
|
|
Registry: reg,
|
|
Scopes: map[string][]string{
|
|
"blog.Blog.Create": {"blog:admin"}, // override service scope
|
|
"blog.Blog.Delete": {"blog:admin", "sudo"}, // add scope to tool without service scope
|
|
},
|
|
})
|
|
if err := s.discoverServices(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Blog.Create should have gateway-level scope (overrides service "blog:write")
|
|
createTool := s.tools["blog.Blog.Create"]
|
|
if createTool == nil {
|
|
t.Fatal("expected tool blog.Blog.Create")
|
|
}
|
|
if len(createTool.Scopes) != 1 || createTool.Scopes[0] != "blog:admin" {
|
|
t.Errorf("expected gateway scopes [blog:admin], got: %v", createTool.Scopes)
|
|
}
|
|
|
|
// Blog.Delete should get gateway-level scopes
|
|
deleteTool := s.tools["blog.Blog.Delete"]
|
|
if deleteTool == nil {
|
|
t.Fatal("expected tool blog.Blog.Delete")
|
|
}
|
|
if len(deleteTool.Scopes) != 2 || deleteTool.Scopes[0] != "blog:admin" || deleteTool.Scopes[1] != "sudo" {
|
|
t.Errorf("expected gateway scopes [blog:admin sudo], got: %v", deleteTool.Scopes)
|
|
}
|
|
}
|