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

335 lines
8.4 KiB
Go

package mcp
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"go-micro.dev/v6/auth"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"go.opentelemetry.io/otel/trace"
)
// newTestTP creates a TracerProvider with an in-memory exporter.
func newTestTP() (*tracetest.InMemoryExporter, trace.TracerProvider) {
exp := tracetest.NewInMemoryExporter()
tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exp))
return exp, tp
}
func TestOTel_SpanCreated(t *testing.T) {
exp, tp := newTestTP()
s := newTestServer(Options{TraceProvider: tp})
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)
// RPC will fail (no backend), but a span should still be created.
spans := exp.GetSpans()
if len(spans) == 0 {
t.Fatal("expected at least one span")
}
span := spans[0]
if span.Name != spanNameToolCall {
t.Errorf("span name = %q, want %q", span.Name, spanNameToolCall)
}
if span.SpanKind != trace.SpanKindServer {
t.Errorf("span kind = %v, want %v", span.SpanKind, trace.SpanKindServer)
}
// Check attributes
assertAttr(t, span.Attributes, AttrToolName, "svc.Echo")
assertAttr(t, span.Attributes, AttrTransport, "http")
}
func TestOTel_SpanAttributes_AuthDenied(t *testing.T) {
exp, tp := newTestTP()
ma := &mockAuth{
accounts: map[string]*auth.Account{
"tok": {ID: "user-1", Scopes: []string{"blog:read"}},
},
}
s := newTestServer(Options{TraceProvider: tp, Auth: ma})
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.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
spans := exp.GetSpans()
if len(spans) == 0 {
t.Fatal("expected a span for denied call")
}
span := spans[0]
assertAttr(t, span.Attributes, AttrAccountID, "user-1")
assertAttrBool(t, span.Attributes, AttrAuthAllowed, false)
assertAttr(t, span.Attributes, AttrAuthDeniedReason, "insufficient scopes")
if span.Status.Code != codes.Error {
t.Errorf("span status = %v, want Error", span.Status.Code)
}
}
func TestOTel_SpanAttributes_AuthAllowed(t *testing.T) {
exp, tp := newTestTP()
ma := &mockAuth{
accounts: map[string]*auth.Account{
"tok": {ID: "user-1", Scopes: []string{"blog:write"}},
},
}
s := newTestServer(Options{TraceProvider: tp, Auth: ma})
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)
// RPC will fail but auth should pass
spans := exp.GetSpans()
if len(spans) == 0 {
t.Fatal("expected a span")
}
span := spans[0]
assertAttr(t, span.Attributes, AttrAccountID, "user-1")
assertAttrBool(t, span.Attributes, AttrAuthAllowed, true)
// RPC fails, so span should have error status
if span.Status.Code != codes.Error {
t.Errorf("span status = %v, want Error (RPC fails with no backend)", span.Status.Code)
}
}
func TestOTel_SpanAttributes_RateLimit(t *testing.T) {
exp, tp := newTestTP()
s := newTestServer(Options{
TraceProvider: tp,
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)
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 passes rate limit
makeReq()
// Second request should be rate limited
code := makeReq()
if code != http.StatusTooManyRequests {
t.Fatalf("status = %d, want %d", code, http.StatusTooManyRequests)
}
spans := exp.GetSpans()
// Find the rate-limited span
var found bool
for _, span := range spans {
for _, attr := range span.Attributes {
if string(attr.Key) == AttrRateLimited && attr.Value.AsBool() {
found = true
break
}
}
}
if !found {
t.Error("expected a span with mcp.rate_limited=true")
}
}
func TestOTel_NoProvider_NoSpan(t *testing.T) {
// Without TraceProvider, tool calls should still work normally.
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)
// Should not panic or error due to missing provider.
// RPC fails as usual.
if rec.Code == 0 {
t.Error("expected a response code")
}
}
func TestOTel_TraceContextPropagation(t *testing.T) {
exp, tp := newTestTP()
s := newTestServer(Options{TraceProvider: tp})
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)
spans := exp.GetSpans()
if len(spans) == 0 {
t.Fatal("expected a span")
}
// The span should have a valid trace ID (non-zero)
span := spans[0]
if !span.SpanContext.TraceID().IsValid() {
t.Error("expected a valid OTel trace ID")
}
if !span.SpanContext.SpanID().IsValid() {
t.Error("expected a valid OTel span ID")
}
}
func TestOTel_MissingToken(t *testing.T) {
exp, tp := newTestTP()
ma := &mockAuth{
accounts: map[string]*auth.Account{},
}
s := newTestServer(Options{TraceProvider: tp, Auth: ma})
s.tools["svc.Do"] = &Tool{
Name: "svc.Do",
Service: "svc",
Endpoint: "Do",
}
body, _ := json.Marshal(map[string]interface{}{
"tool": "svc.Do",
"input": map[string]interface{}{},
})
req := httptest.NewRequest(http.MethodPost, "/mcp/call", bytes.NewReader(body))
// No Authorization header
rec := httptest.NewRecorder()
s.handleCallTool(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
spans := exp.GetSpans()
if len(spans) == 0 {
t.Fatal("expected a span even for missing token")
}
span := spans[0]
assertAttrBool(t, span.Attributes, AttrAuthAllowed, false)
assertAttr(t, span.Attributes, AttrAuthDeniedReason, "missing token")
}
func TestOTel_StartToolSpan_NilProvider(t *testing.T) {
s := newTestServer(Options{})
ctx, span := s.startToolSpan(context.Background(), "svc.Test", "http", "test-trace-id")
defer span.End()
// Should return a noop span, not panic
if ctx == nil {
t.Error("expected non-nil context")
}
if span == nil {
t.Error("expected non-nil span (even if noop)")
}
}
// --- helpers ---
func assertAttr(t *testing.T, attrs []attribute.KeyValue, key, want string) {
t.Helper()
for _, attr := range attrs {
if string(attr.Key) == key {
if got := attr.Value.AsString(); got != want {
t.Errorf("attribute %s = %q, want %q", key, got, want)
}
return
}
}
t.Errorf("attribute %s not found", key)
}
func assertAttrBool(t *testing.T, attrs []attribute.KeyValue, key string, want bool) {
t.Helper()
for _, attr := range attrs {
if string(attr.Key) == key {
if got := attr.Value.AsBool(); got != want {
t.Errorf("attribute %s = %v, want %v", key, got, want)
}
return
}
}
t.Errorf("attribute %s not found", key)
}