From f80275a6a6368c5ccecf330a9493a882a4b2b1d6 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Wed, 11 Mar 2026 21:34:17 +0000 Subject: [PATCH 01/46] feat(mcp): add OAuth 2.1 authorization with per-tool scope extraction and configurable scope challenge modes --- demo/go.mod | 2 +- demo/go.sum | 4 +- router-tests/cmd/oauth-server/main.go | 438 +++++++++++ router-tests/go.mod | 35 +- router-tests/go.sum | 62 +- router-tests/mcp_auth_e2e_test.go | 370 +++++++++ router-tests/mcp_auth_harness_example.go | 242 ++++++ router-tests/mcp_oauth_e2e_test.go | 283 +++++++ router-tests/protocol/mcp_test.go | 14 +- router-tests/testenv/testenv.go | 26 +- router-tests/testutil/auth_helpers.go | 63 ++ router-tests/testutil/jwt_helper.go | 195 +++++ router-tests/testutil/oauth_server.go | 544 ++++++++++++++ router-tests/testutil/oauth_server_test.go | 234 ++++++ router/core/graph_server.go | 2 +- router/core/router.go | 10 + router/go.mod | 45 +- router/go.sum | 72 +- router/pkg/config/config.go | 56 +- router/pkg/config/config.schema.json | 363 ++++++++- .../pkg/config/testdata/config_defaults.json | 16 +- router/pkg/config/testdata/config_full.json | 16 +- router/pkg/mcpserver/auth_middleware.go | 465 ++++++++++++ router/pkg/mcpserver/auth_middleware_test.go | 708 ++++++++++++++++++ router/pkg/mcpserver/errors.go | 34 + .../mcpserver/execute_graphql_scope_test.go | 204 +++++ router/pkg/mcpserver/operation_manager.go | 17 +- router/pkg/mcpserver/schema_compiler.go | 2 +- router/pkg/mcpserver/scope_challenge.go | 108 +++ router/pkg/mcpserver/scope_challenge.md | 93 +++ router/pkg/mcpserver/scope_challenge_test.go | 339 +++++++++ router/pkg/mcpserver/scope_extractor.go | 157 ++++ router/pkg/mcpserver/scope_extractor_test.go | 459 ++++++++++++ router/pkg/mcpserver/server.go | 598 +++++++++++---- router/pkg/schemaloader/loader.go | 6 +- router/pkg/schemaloader/loader_test.go | 3 +- 36 files changed, 6020 insertions(+), 265 deletions(-) create mode 100644 router-tests/cmd/oauth-server/main.go create mode 100644 router-tests/mcp_auth_e2e_test.go create mode 100644 router-tests/mcp_auth_harness_example.go create mode 100644 router-tests/mcp_oauth_e2e_test.go create mode 100644 router-tests/testutil/auth_helpers.go create mode 100644 router-tests/testutil/jwt_helper.go create mode 100644 router-tests/testutil/oauth_server.go create mode 100644 router-tests/testutil/oauth_server_test.go create mode 100644 router/pkg/mcpserver/auth_middleware.go create mode 100644 router/pkg/mcpserver/auth_middleware_test.go create mode 100644 router/pkg/mcpserver/errors.go create mode 100644 router/pkg/mcpserver/execute_graphql_scope_test.go create mode 100644 router/pkg/mcpserver/scope_challenge.go create mode 100644 router/pkg/mcpserver/scope_challenge.md create mode 100644 router/pkg/mcpserver/scope_challenge_test.go create mode 100644 router/pkg/mcpserver/scope_extractor.go create mode 100644 router/pkg/mcpserver/scope_extractor_test.go diff --git a/demo/go.mod b/demo/go.mod index 0bef81ddf0..208d1cdb72 100644 --- a/demo/go.mod +++ b/demo/go.mod @@ -95,7 +95,7 @@ require ( github.com/logrusorgru/aurora/v4 v4.0.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/mark3labs/mcp-go v0.36.0 // indirect + github.com/mark3labs/mcp-go v0.43.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/md5-simd v1.1.2 // indirect diff --git a/demo/go.sum b/demo/go.sum index f9a9527b44..def336f529 100644 --- a/demo/go.sum +++ b/demo/go.sum @@ -222,8 +222,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mark3labs/mcp-go v0.36.0 h1:rIZaijrRYPeSbJG8/qNDe0hWlGrCJ7FWHNMz2SQpTis= -github.com/mark3labs/mcp-go v0.36.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= +github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= diff --git a/router-tests/cmd/oauth-server/main.go b/router-tests/cmd/oauth-server/main.go new file mode 100644 index 0000000000..38f5a5be70 --- /dev/null +++ b/router-tests/cmd/oauth-server/main.go @@ -0,0 +1,438 @@ +/* +Standalone OAuth 2.1 Authorization Server for local MCP development and testing. + +Provides all endpoints needed by the official MCP TypeScript SDK's ClientCredentialsProvider: + - /.well-known/oauth-authorization-server (AS metadata, RFC 8414) + - /.well-known/jwks.json (JWKS for token verification) + - /token (client_credentials + authorization_code) + - /register (dynamic client registration, RFC 7591) + - /authorize (auto-approve for testing) + +Usage: + + go run ./cmd/oauth-server + + # or with options + go run ./cmd/oauth-server -port 9099 -client-id test-mcp-client -client-secret test-mcp-secret -scopes "mcp:connect mcp:tools:read mcp:tools:write" + +Then configure router/mcp.config.yaml: + + mcp: + oauth: + enabled: true + authorization_server_url: "http://localhost:9099" + jwks: + - url: "http://localhost:9099/.well-known/jwks.json" + refresh_interval: 1m + algorithms: ["RS256"] + +Run with the MCP TypeScript SDK client: + + MCP_SERVER_URL=http://localhost:5025/mcp \ + MCP_CLIENT_ID=test-mcp-client \ + MCP_CLIENT_SECRET=test-mcp-secret \ + pnpm test +*/ +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + "github.com/MicahParks/jwkset" + "github.com/golang-jwt/jwt/v5" + "github.com/wundergraph/cosmo/router-tests/jwks" +) + +var ( + portFlag = flag.String("port", "9099", "Port to listen on") + clientIDFlag = flag.String("client-id", "test-mcp-client", "Pre-registered client ID") + clientSecretFlag = flag.String("client-secret", "test-mcp-secret", "Pre-registered client secret") + scopesFlag = flag.String("scopes", "mcp:connect mcp:tools:read mcp:tools:write", "Default scopes for the pre-registered client (space-separated)") +) + +func main() { + flag.Parse() + + srv, err := newOAuthServer(*portFlag, *clientIDFlag, *clientSecretFlag, *scopesFlag) + if err != nil { + log.Fatalf("Failed to create OAuth server: %v", err) + } + + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, os.Interrupt, syscall.SIGTERM) + + go func() { + log.Printf("OAuth server listening on http://localhost:%s", *portFlag) + log.Printf(" JWKS: http://localhost:%s/.well-known/jwks.json", *portFlag) + log.Printf(" Metadata: http://localhost:%s/.well-known/oauth-authorization-server", *portFlag) + log.Printf(" Token: http://localhost:%s/token", *portFlag) + log.Printf(" Register: http://localhost:%s/register", *portFlag) + log.Printf(" Client: %s / %s (scopes: %s)", *clientIDFlag, *clientSecretFlag, *scopesFlag) + + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("Server error: %v", err) + } + }() + + // Print a sample token for manual testing + token, err := srv.handler.createToken(*clientIDFlag, *scopesFlag) + if err == nil { + log.Printf("\nSample Bearer token (for manual curl/playground testing):\n%s\n", token) + } + + <-sigs + log.Println("Shutting down...") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(ctx) +} + +// --------------------------------------------------------------------------- +// Server +// --------------------------------------------------------------------------- + +type oauthHandler struct { + provider jwks.Crypto + keyID string + issuer string + jwksURL string + storage jwkset.Storage + + mu sync.RWMutex + clients map[string]*client + codes map[string]*authCode +} + +type client struct { + id string + secret string + scope string +} + +type authCode struct { + clientID string + scope string + createdAt time.Time +} + +type serverWithHandler struct { + *http.Server + handler *oauthHandler +} + +func newOAuthServer(port, clientID, clientSecret, defaultScopes string) (*serverWithHandler, error) { + cryptoProvider, err := jwks.NewRSACrypto("test_rsa", jwkset.AlgRS256, 2048) + if err != nil { + return nil, fmt.Errorf("RSA keygen: %w", err) + } + + jwkStorage := jwkset.NewMemoryStorage() + jwk, err := cryptoProvider.MarshalJWK() + if err != nil { + return nil, fmt.Errorf("marshal JWK: %w", err) + } + if err := jwkStorage.KeyWrite(context.Background(), jwk); err != nil { + return nil, fmt.Errorf("store JWK: %w", err) + } + + baseURL := fmt.Sprintf("http://localhost:%s", port) + + h := &oauthHandler{ + provider: cryptoProvider, + keyID: "test_rsa", + issuer: baseURL, + jwksURL: baseURL + "/.well-known/jwks.json", + storage: jwkStorage, + clients: make(map[string]*client), + codes: make(map[string]*authCode), + } + + // Pre-register client + h.clients[clientID] = &client{id: clientID, secret: clientSecret, scope: defaultScopes} + + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/jwks.json", h.handleJWKS) + mux.HandleFunc("/.well-known/oauth-authorization-server", h.handleASMetadata) + mux.HandleFunc("/token", h.handleToken) + mux.HandleFunc("/register", h.handleRegister) + mux.HandleFunc("/authorize", h.handleAuthorize) + + return &serverWithHandler{ + Server: &http.Server{Addr: ":" + port, Handler: withCORS(mux)}, + handler: h, + }, nil +} + +// --------------------------------------------------------------------------- +// Endpoints +// --------------------------------------------------------------------------- + +func (h *oauthHandler) handleJWKS(w http.ResponseWriter, _ *http.Request) { + raw, err := h.storage.JSON(context.Background()) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(raw) +} + +func (h *oauthHandler) handleASMetadata(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": h.issuer, + "token_endpoint": h.issuer + "/token", + "authorization_endpoint": h.issuer + "/authorize", + "registration_endpoint": h.issuer + "/register", + "jwks_uri": h.jwksURL, + "response_types_supported": []string{"code"}, + "grant_types_supported": []string{"client_credentials", "authorization_code"}, + "token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"}, + "code_challenge_methods_supported": []string{"S256"}, + }) +} + +func (h *oauthHandler) handleToken(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + tokenError(w, "invalid_request", "POST required", http.StatusMethodNotAllowed) + return + } + _ = r.ParseForm() + + switch r.FormValue("grant_type") { + case "client_credentials": + h.handleClientCredentials(w, r) + case "authorization_code": + h.handleCodeExchange(w, r) + default: + tokenError(w, "unsupported_grant_type", "unsupported grant_type", http.StatusBadRequest) + } +} + +func (h *oauthHandler) handleClientCredentials(w http.ResponseWriter, r *http.Request) { + clientID, clientSecret, ok := authenticateClient(r) + if !ok { + tokenError(w, "invalid_client", "client authentication failed", http.StatusUnauthorized) + return + } + + h.mu.RLock() + c, exists := h.clients[clientID] + h.mu.RUnlock() + + if !exists || c.secret != clientSecret { + tokenError(w, "invalid_client", "unknown client or bad secret", http.StatusUnauthorized) + return + } + + scope := r.FormValue("scope") + if scope == "" { + scope = c.scope + } + + h.issueTokenResponse(w, clientID, scope) +} + +func (h *oauthHandler) handleCodeExchange(w http.ResponseWriter, r *http.Request) { + code := r.FormValue("code") + if code == "" { + tokenError(w, "invalid_request", "missing code", http.StatusBadRequest) + return + } + + h.mu.Lock() + pending, exists := h.codes[code] + if exists { + delete(h.codes, code) + } + h.mu.Unlock() + + if !exists || time.Since(pending.createdAt) > 60*time.Second { + tokenError(w, "invalid_grant", "unknown or expired code", http.StatusBadRequest) + return + } + + clientID, clientSecret, ok := authenticateClient(r) + if !ok { + tokenError(w, "invalid_client", "client authentication failed", http.StatusUnauthorized) + return + } + + h.mu.RLock() + c, clientExists := h.clients[clientID] + h.mu.RUnlock() + + if !clientExists || c.secret != clientSecret || pending.clientID != clientID { + tokenError(w, "invalid_client", "client mismatch", http.StatusUnauthorized) + return + } + + h.issueTokenResponse(w, clientID, pending.scope) +} + +func (h *oauthHandler) handleAuthorize(w http.ResponseWriter, r *http.Request) { + clientID := r.URL.Query().Get("client_id") + redirectURI := r.URL.Query().Get("redirect_uri") + scope := r.URL.Query().Get("scope") + state := r.URL.Query().Get("state") + + if clientID == "" || redirectURI == "" { + http.Error(w, "missing client_id or redirect_uri", http.StatusBadRequest) + return + } + + code := randomHex(32) + h.mu.Lock() + h.codes[code] = &authCode{clientID: clientID, scope: scope, createdAt: time.Now()} + h.mu.Unlock() + + location := fmt.Sprintf("%s?code=%s", redirectURI, code) + if state != "" { + location += "&state=" + state + } + http.Redirect(w, r, location, http.StatusFound) +} + +func (h *oauthHandler) handleRegister(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST required", http.StatusMethodNotAllowed) + return + } + + var req struct { + ClientName string `json:"client_name"` + GrantTypes []string `json:"grant_types"` + RedirectURIs []string `json:"redirect_uris"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + Scope string `json:"scope"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad JSON", http.StatusBadRequest) + return + } + + id := "dyn-" + randomHex(16) + secret := "secret-" + randomHex(24) + + h.mu.Lock() + h.clients[id] = &client{id: id, secret: secret, scope: req.Scope} + h.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{ + "client_id": id, + "client_secret": secret, + "client_name": req.ClientName, + "grant_types": req.GrantTypes, + "redirect_uris": req.RedirectURIs, + "token_endpoint_auth_method": req.TokenEndpointAuthMethod, + }) +} + +// --------------------------------------------------------------------------- +// Token helpers +// --------------------------------------------------------------------------- + +func (h *oauthHandler) issueTokenResponse(w http.ResponseWriter, sub, scope string) { + accessToken, err := h.createToken(sub, scope) + if err != nil { + tokenError(w, "server_error", "token signing failed", http.StatusInternalServerError) + return + } + + resp := map[string]any{ + "access_token": accessToken, + "token_type": "Bearer", + "expires_in": 3600, + } + if scope != "" { + resp["scope"] = scope + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(resp) +} + +func (h *oauthHandler) createToken(sub, scope string) (string, error) { + now := time.Now() + claims := jwt.MapClaims{ + "iss": h.issuer, + "aud": "test-audience", + "sub": sub, + "iat": now.Unix(), + "exp": now.Add(1 * time.Hour).Unix(), + "client_id": sub, + } + if scope != "" { + claims["scope"] = scope + } + + token := jwt.NewWithClaims(h.provider.SigningMethod(), claims) + token.Header[jwkset.HeaderKID] = h.keyID + return token.SignedString(h.provider.PrivateKey()) +} + +func authenticateClient(r *http.Request) (string, string, bool) { + if authHeader := r.Header.Get("Authorization"); strings.HasPrefix(authHeader, "Basic ") { + decoded, err := base64.StdEncoding.DecodeString(authHeader[6:]) + if err == nil { + if parts := strings.SplitN(string(decoded), ":", 2); len(parts) == 2 { + return parts[0], parts[1], true + } + } + } + id, secret := r.FormValue("client_id"), r.FormValue("client_secret") + if id != "" && secret != "" { + return id, secret, true + } + return "", "", false +} + +func tokenError(w http.ResponseWriter, errCode, desc string, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": errCode, "error_description": desc}) +} + +func randomHex(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// withCORS wraps an http.Handler with permissive CORS headers for browser-based +// MCP clients (e.g. MCP Inspector). The TypeScript SDK fetches +// /.well-known/oauth-authorization-server cross-origin from the browser. +func withCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept, MCP-Protocol-Version") + w.Header().Set("Access-Control-Max-Age", "86400") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} \ No newline at end of file diff --git a/router-tests/go.mod b/router-tests/go.mod index 43802d65a8..0fc2f59d24 100644 --- a/router-tests/go.mod +++ b/router-tests/go.mod @@ -7,14 +7,15 @@ require ( github.com/MicahParks/jwkset v0.11.0 github.com/buger/jsonparser v1.1.2 github.com/cloudflare/backoff v0.0.0-20240920015135-e46b80a3a7d0 - github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/go-containerregistry v0.20.3 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.1 github.com/hashicorp/go-cleanhttp v0.5.2 github.com/hashicorp/go-retryablehttp v0.7.7 github.com/hasura/go-graphql-client v0.14.3 - github.com/mark3labs/mcp-go v0.36.0 + github.com/mark3labs/mcp-go v0.43.2 + github.com/modelcontextprotocol/go-sdk v1.4.0 github.com/nats-io/nats.go v1.35.0 github.com/prometheus/client_golang v1.19.1 github.com/prometheus/client_model v0.6.1 @@ -24,9 +25,9 @@ require ( github.com/twmb/franz-go v1.16.1 github.com/twmb/franz-go/pkg/kadm v1.11.0 github.com/wundergraph/astjson v1.1.0 - github.com/wundergraph/cosmo/demo v0.0.0-20260319123623-f186a0f724f6 + github.com/wundergraph/cosmo/demo v0.0.0-20260213130455-6e3277e7b850 github.com/wundergraph/cosmo/demo/pkg/subgraphs/projects v0.0.0-20250715110703-10f2e5f9c79e - github.com/wundergraph/cosmo/router v0.0.0-20260319123623-f186a0f724f6 + github.com/wundergraph/cosmo/router v0.0.0-20260318232543-0e5fa811a191 github.com/wundergraph/cosmo/router-plugin v0.0.0-20250808194725-de123ba1c65e github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.267 go.opentelemetry.io/otel v1.39.0 @@ -36,10 +37,10 @@ require ( go.uber.org/atomic v1.11.0 go.uber.org/goleak v1.3.0 go.uber.org/zap v1.27.0 - golang.org/x/net v0.48.0 - golang.org/x/sys v0.39.0 - google.golang.org/grpc v1.79.3 - google.golang.org/protobuf v1.36.10 + golang.org/x/net v0.49.0 + golang.org/x/sys v0.40.0 + google.golang.org/grpc v1.71.0 + google.golang.org/protobuf v1.36.9 gopkg.in/yaml.v3 v3.0.1 ) @@ -89,6 +90,7 @@ require ( github.com/goccy/go-yaml v1.17.1 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang/protobuf v1.5.4 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect @@ -133,6 +135,8 @@ require ( github.com/rs/xid v1.5.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.3 // indirect github.com/sergi/go-diff v1.3.1 // indirect github.com/shirou/gopsutil/v3 v3.24.3 // indirect github.com/shoenig/go-m1cpu v0.1.7 // indirect @@ -165,21 +169,22 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.23.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 // indirect go.opentelemetry.io/otel/exporters/prometheus v0.50.0 // indirect - go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.36.0 // indirect go.opentelemetry.io/proto/otlp v1.4.0 // indirect go.uber.org/automaxprocs v1.5.3 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/ratelimit v0.3.1 // indirect go.withmatt.com/connect-brotli v0.4.0 // indirect - golang.org/x/crypto v0.46.0 // indirect + golang.org/x/crypto v0.47.0 // indirect golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect - golang.org/x/mod v0.30.0 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/text v0.32.0 // indirect + golang.org/x/text v0.33.0 // indirect golang.org/x/time v0.9.0 // indirect - golang.org/x/tools v0.39.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + golang.org/x/tools v0.41.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a // indirect gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect ) diff --git a/router-tests/go.sum b/router-tests/go.sum index 9bb30f878f..7059ffe5be 100644 --- a/router-tests/go.sum +++ b/router-tests/go.sum @@ -126,8 +126,8 @@ github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -139,6 +139,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.3 h1:oNx7IdTI936V8CQRveCjaxOiegWwvM7kqkbXTpyiovI= github.com/google/go-containerregistry v0.20.3/go.mod h1:w00pIgBRDVUDFM6bq+Qx8lwNWK+cxgCuX1vd3PIBDNI= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -206,8 +208,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mark3labs/mcp-go v0.36.0 h1:rIZaijrRYPeSbJG8/qNDe0hWlGrCJ7FWHNMz2SQpTis= -github.com/mark3labs/mcp-go v0.36.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= +github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -228,6 +230,8 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8= +github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nats-io/nats.go v1.35.0 h1:XFNqNM7v5B+MQMKqVGAyHwYhyKb48jrenXNxIU20ULk= @@ -287,6 +291,10 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+x github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sebdah/goldie/v2 v2.7.1 h1:PkBHymaYdtvEkZV7TmyqKxdmn5/Vcj+8TpATWZjnG5E= github.com/sebdah/goldie/v2 v2.7.1/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= @@ -417,20 +425,22 @@ go.withmatt.com/connect-brotli v0.4.0 h1:7ObWkYmEbUXK3EKglD0Lgj0BBnnD3jNdAxeDRct go.withmatt.com/connect-brotli v0.4.0/go.mod h1:c2eELz56za+/Mxh1yJrlglZ4VM9krpOCPqS2Vxf8NVk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= @@ -450,31 +460,31 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gonum.org/v1/gonum v0.14.0 h1:2NiG67LD1tEH0D7kM+ps2V+fXmsAnpUeec7n8tcr4S0= +gonum.org/v1/gonum v0.14.0/go.mod h1:AoWeoz0becf9QMWtE8iWXNXc27fK4fNeHNf/oMejGfU= +google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a h1:DMCgtIAIQGZqJXMVzJF4MV8BlWoJh2ZuFiRdAleyr58= +google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a/go.mod h1:y2yVLIE/CSMCPXaHnSKXxu1spLPnglFLegmgdY23uuE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a h1:tPE/Kp+x9dMSwUm/uM0JKK0IfdiJkwAbSMSeZBXXJXc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y= gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/router-tests/mcp_auth_e2e_test.go b/router-tests/mcp_auth_e2e_test.go new file mode 100644 index 0000000000..896c2e4afb --- /dev/null +++ b/router-tests/mcp_auth_e2e_test.go @@ -0,0 +1,370 @@ +package integration + +import ( + "context" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/wundergraph/cosmo/router-tests/testenv" + "github.com/wundergraph/cosmo/router-tests/testutil" + "github.com/wundergraph/cosmo/router/pkg/config" +) + +// previewToken returns a truncated preview of a token for logging purposes. +// Returns the full token if shorter than n characters, otherwise returns first n characters with "...". +func previewToken(token string, n int) string { + if len(token) <= n { + return token + } + return token[:n] + "..." +} + +// authRoundTripper wraps an http.RoundTripper and adds Authorization headers +// It also captures the last HTTP response for error analysis +type authRoundTripper struct { + base http.RoundTripper + token string + lastResponse *http.Response +} + +func (a *authRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + // Clone the request to avoid modifying the original + req = req.Clone(req.Context()) + + // Add Authorization header if token is set + if a.token != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", a.token)) + } + + resp, err := a.base.RoundTrip(req) + // Capture response for error analysis + a.lastResponse = resp + return resp, err +} + +// MCPAuthClient wraps the official MCP client with authorization support +type MCPAuthClient struct { + endpoint string + transport *mcp.StreamableClientTransport + roundTripper *authRoundTripper + client *mcp.Client + session *mcp.ClientSession +} + +// AuthError represents an HTTP authentication/authorization error +type AuthError struct { + StatusCode int + ErrorCode string + RequiredScopes []string + ResourceMetadataURL string + ErrorDescription string +} + +func (e *AuthError) Error() string { + if e.ErrorCode == "insufficient_scope" { + return fmt.Sprintf("HTTP %d: insufficient scope - required scopes: %v", e.StatusCode, e.RequiredScopes) + } + return fmt.Sprintf("HTTP %d: %s - %s", e.StatusCode, e.ErrorCode, e.ErrorDescription) +} + +// NewMCPAuthClient creates a new MCP client with authorization support +func NewMCPAuthClient(endpoint string, initialToken string) *MCPAuthClient { + // Create a custom round tripper that adds Authorization headers + roundTripper := &authRoundTripper{ + base: http.DefaultTransport, + token: initialToken, + } + + // Create HTTP client with custom round tripper + httpClient := &http.Client{ + Transport: roundTripper, + } + + // Create streamable transport + transport := &mcp.StreamableClientTransport{ + Endpoint: endpoint, + HTTPClient: httpClient, + } + + // Create MCP client + client := mcp.NewClient(&mcp.Implementation{ + Name: "test-client", + Version: "1.0.0", + }, nil) + + return &MCPAuthClient{ + endpoint: endpoint, + transport: transport, + roundTripper: roundTripper, + client: client, + } +} + +// Connect establishes the MCP connection and initializes the session +func (c *MCPAuthClient) Connect(ctx context.Context) error { + session, err := c.client.Connect(ctx, c.transport, nil) + if err != nil { + return fmt.Errorf("failed to connect: %w", err) + } + c.session = session + return nil +} + +// SetToken updates the authorization token +// This is the KEY method - it allows changing tokens without reconnecting! +func (c *MCPAuthClient) SetToken(token string) { + c.roundTripper.token = token +} + +// CallTool calls an MCP tool +// Returns *AuthError if the request fails due to HTTP 401/403 +func (c *MCPAuthClient) CallTool(ctx context.Context, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { + params := &mcp.CallToolParams{ + Name: toolName, + Arguments: arguments, + } + + result, err := c.session.CallTool(ctx, params) + if err != nil { + // Check if this was an HTTP auth error + if authErr := c.checkAuthError(); authErr != nil { + return nil, authErr + } + return nil, err + } + + return result, nil +} + +// checkAuthError checks if the last HTTP response was an auth error (401/403) +// and returns an AuthError with parsed WWW-Authenticate header information +func (c *MCPAuthClient) checkAuthError() *AuthError { + if c.roundTripper.lastResponse == nil { + return nil + } + + resp := c.roundTripper.lastResponse + + // Check for 401 Unauthorized or 403 Forbidden + if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusForbidden { + return nil + } + + // Parse WWW-Authenticate header + authHeader := resp.Header.Get("WWW-Authenticate") + if authHeader == "" { + return &AuthError{ + StatusCode: resp.StatusCode, + ErrorCode: "authentication_required", + } + } + + params := testutil.ParseWWWAuthenticateParams(authHeader) + + authErr := &AuthError{ + StatusCode: resp.StatusCode, + ErrorCode: params["error"], + ResourceMetadataURL: params["resource_metadata"], + ErrorDescription: params["error_description"], + } + + // Parse required scopes (space-separated) + if scopeStr := params["scope"]; scopeStr != "" { + authErr.RequiredScopes = strings.Fields(scopeStr) + } + + return authErr +} + +// Close closes the MCP session +func (c *MCPAuthClient) Close() error { + if c.session != nil { + return c.session.Close() + } + return nil +} + +// TestMCPAuthorizationWithOfficialSDK demonstrates authorization testing with the official MCP Go SDK +func TestMCPAuthorizationWithOfficialSDK(t *testing.T) { + t.Run("Basic connection with token", func(t *testing.T) { + testenv.Run(t, &testenv.Config{ + MCP: config.MCPConfiguration{ + Enabled: true, + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + ctx := context.Background() + + // Create MCP client with initial token + token := "test-token-with-read-scopes" + mcpClient := NewMCPAuthClient(xEnv.GetMCPServerAddr(), token) + + // Connect and initialize + err := mcpClient.Connect(ctx) + require.NoError(t, err) + defer mcpClient.Close() //nolint:errcheck + + t.Logf("✓ Connected to MCP server with token: %s", previewToken(token, 20)) + + // Call a tool + result, err := mcpClient.CallTool(ctx, "execute_operation_my_employees", map[string]any{ + "criteria": map[string]any{}, + }) + + // Without authorization configured, this should work + require.NoError(t, err) + require.NotNil(t, result) + t.Logf("✓ Successfully called tool") + }) + }) + + t.Run("Scope upgrade on persistent session", func(t *testing.T) { + // This test demonstrates the KEY concept: + // - Establish session with token1 + // - Get "insufficient scopes" error + // - Update token (SetToken) + // - Retry on SAME session with new token + + testenv.Run(t, &testenv.Config{ + MCP: config.MCPConfiguration{ + Enabled: true, + // TODO: Add authorization configuration when implemented + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + ctx := context.Background() + + // Step 1: Connect with limited token + readToken := "token-with-scope-mcp:tools:read" + mcpClient := NewMCPAuthClient(xEnv.GetMCPServerAddr(), readToken) + + err := mcpClient.Connect(ctx) + require.NoError(t, err) + defer mcpClient.Close() //nolint:errcheck + + t.Logf("✓ Step 1: Connected with read-only token") + t.Logf(" Token: %s", readToken[:30]+"...") + + // Step 2: Call read operation (should succeed) + result, err := mcpClient.CallTool(ctx, "execute_operation_my_employees", map[string]any{ + "criteria": map[string]any{}, + }) + require.NoError(t, err) + require.NotNil(t, result) + t.Logf("✓ Step 2: Read operation succeeded") + + // Step 3: Try write operation (should fail with insufficient scopes) + // NOTE: This would fail if authorization is configured + _, err = mcpClient.CallTool(ctx, "execute_operation_update_mood", map[string]any{ + "employeeID": 1, + "mood": "HAPPY", + }) + + // Without authorization, this succeeds. With authorization, check for scope error + if err != nil { + t.Logf("✓ Step 3: Write operation failed (expected with auth): %v", err) + + // In a real scenario with authorization: + // 1. Parse error to get required scopes + // 2. User goes through OAuth flow + // 3. Get new token with required scopes + + // Step 4: Update token on SAME session + writeToken := "token-with-scope-mcp:tools:read,mcp:tools:write" + mcpClient.SetToken(writeToken) + t.Logf("✓ Step 4: Updated token (same session)") + t.Logf(" New Token: %s", writeToken[:30]+"...") + + // Step 5: Retry write operation with upgraded token + result, err := mcpClient.CallTool(ctx, "execute_operation_update_mood", map[string]any{ + "employeeID": 1, + "mood": "HAPPY", + }) + + assert.NoError(t, err) + assert.NotNil(t, result) + t.Logf("✓ Step 5: Write operation succeeded with upgraded token") + } else { + t.Logf("✓ Step 3: Write operation succeeded (no authorization configured)") + } + }) + }) + + t.Run("Multiple token changes on same session", func(t *testing.T) { + testenv.Run(t, &testenv.Config{ + MCP: config.MCPConfiguration{ + Enabled: true, + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + ctx := context.Background() + + mcpClient := NewMCPAuthClient(xEnv.GetMCPServerAddr(), "initial-token") + err := mcpClient.Connect(ctx) + require.NoError(t, err) + defer mcpClient.Close() //nolint:errcheck + + t.Logf("✓ Connected with initial token") + + // Simulate multiple scope upgrades + tokens := []string{ + "token-with-basic-scopes", + "token-with-read-scopes", + "token-with-write-scopes", + "token-with-admin-scopes", + } + + for i, token := range tokens { + mcpClient.SetToken(token) + + // Make a call with the new token + result, err := mcpClient.CallTool(ctx, "execute_operation_my_employees", map[string]any{ + "criteria": map[string]any{}, + }) + + require.NoError(t, err) + require.NotNil(t, result) + t.Logf("✓ Request %d succeeded with token: %s", i+1, previewToken(token, 25)) + } + + t.Logf("✓ All token changes worked on same session") + }) + }) +} + +// Example_mcpAuthorizationFlow shows how to use the auth client +func Example_mcpAuthorizationFlow() { + ctx := context.Background() + + // Create client with initial token + client := NewMCPAuthClient("http://localhost:3000/mcp", "initial-token") + defer client.Close() //nolint:errcheck + + // Connect + if err := client.Connect(ctx); err != nil { + panic(err) + } + + // Try to call a tool + _, err := client.CallTool(ctx, "some_tool", map[string]any{}) + + // If we get insufficient scopes error + if err != nil { + // 1. User goes through OAuth flow (not shown) + // 2. Get new token with more scopes + newToken := "token-with-more-scopes" + + // 3. Update token on SAME session + client.SetToken(newToken) + + // 4. Retry the tool call + _, err = client.CallTool(ctx, "some_tool", map[string]any{}) + if err != nil { + panic(err) + } + } + + fmt.Println("Success!") +} diff --git a/router-tests/mcp_auth_harness_example.go b/router-tests/mcp_auth_harness_example.go new file mode 100644 index 0000000000..42d9e1550b --- /dev/null +++ b/router-tests/mcp_auth_harness_example.go @@ -0,0 +1,242 @@ +package integration + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// Example demonstrating the actual HTTP-level MCP authorization flow +// This shows how tokens are sent in HTTP headers, not JSON-RPC + +type MCPClient struct { + serverURL string + httpClient *http.Client + sessionID string // Persistent across requests +} + +// Step 1: Initialize - First HTTP POST with initial token +func (c *MCPClient) Initialize(ctx context.Context, token string) error { + // Create JSON-RPC initialize request + jsonRPCRequest := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": map[string]interface{}{ + "protocolVersion": "2024-11-05", + "clientInfo": map[string]string{ + "name": "test-client", + "version": "1.0.0", + }, + }, + } + + // HTTP POST #1 + req, _ := http.NewRequestWithContext(ctx, "POST", c.serverURL, toReader(jsonRPCRequest)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) // ← Token in HTTP header + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() //nolint:errcheck + + // Extract session ID from HTTP response headers + c.sessionID = resp.Header.Get("Mcp-Session-Id") // ← Session ID from HTTP header + + fmt.Printf("✓ HTTP POST #1 - Initialize\n") + fmt.Printf(" Request Header: Authorization: Bearer %s\n", token[:20]+"...") + fmt.Printf(" Response Header: Mcp-Session-Id: %s\n", c.sessionID) + + return nil +} + +// Step 2: Call tool with initial token (limited scopes) +func (c *MCPClient) CallToolWithLimitedScopes(ctx context.Context, token string) error { + jsonRPCRequest := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": map[string]interface{}{ + "name": "execute_operation_update_mood", + "arguments": map[string]interface{}{ + "employeeID": 1, + "mood": "HAPPY", + }, + }, + } + + // HTTP POST #2 - Same session, same token + req, _ := http.NewRequestWithContext(ctx, "POST", c.serverURL, toReader(jsonRPCRequest)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) // ← Same token + req.Header.Set("Mcp-Session-Id", c.sessionID) // ← Same session ID + + fmt.Printf("\n✓ HTTP POST #2 - Call tool (limited scopes)\n") + fmt.Printf(" Request Header: Authorization: Bearer %s\n", token[:20]+"...") + fmt.Printf(" Request Header: Mcp-Session-Id: %s\n", c.sessionID) + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() //nolint:errcheck + + // Parse JSON-RPC response + var jsonRPCResp struct { + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + RequiredScopes []string `json:"required_scopes"` // ← Scopes in JSON-RPC error data + } `json:"data"` + } `json:"error"` + } + json.NewDecoder(resp.Body).Decode(&jsonRPCResp) //nolint:errcheck + + if jsonRPCResp.Error != nil { + fmt.Printf(" Response Body: JSON-RPC Error\n") + fmt.Printf(" {\n") + fmt.Printf(" \"error\": {\n") + fmt.Printf(" \"code\": %d,\n", jsonRPCResp.Error.Code) + fmt.Printf(" \"message\": \"%s\",\n", jsonRPCResp.Error.Message) + fmt.Printf(" \"data\": {\n") + fmt.Printf(" \"required_scopes\": %v\n", jsonRPCResp.Error.Data.RequiredScopes) + fmt.Printf(" }\n") + fmt.Printf(" }\n") + fmt.Printf(" }\n") + return fmt.Errorf("insufficient scopes: %v", jsonRPCResp.Error.Data.RequiredScopes) + } + + return nil +} + +// Step 3: Obtain new token (simulated OAuth flow) +func (c *MCPClient) ObtainNewToken(requiredScopes []string) string { + // In reality, this would: + // 1. Open browser to authorization server + // 2. User consents to new scopes + // 3. Exchange auth code for new access token + // 4. Return new access token + + newToken := fmt.Sprintf("new-token-with-scopes-%v", requiredScopes) + fmt.Printf("\n✓ OAuth Flow - Obtained new token\n") + fmt.Printf(" Scopes: %v\n", requiredScopes) + fmt.Printf(" New Token: %s\n", newToken[:30]+"...") + return newToken +} + +// Step 4: Retry tool call with upgraded token +func (c *MCPClient) CallToolWithUpgradedToken(ctx context.Context, newToken string) error { + jsonRPCRequest := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": map[string]interface{}{ + "name": "execute_operation_update_mood", + "arguments": map[string]interface{}{ + "employeeID": 1, + "mood": "HAPPY", + }, + }, + } + + // HTTP POST #3 - SAME session, DIFFERENT token + req, _ := http.NewRequestWithContext(ctx, "POST", c.serverURL, toReader(jsonRPCRequest)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", newToken)) // ← NEW token (different Authorization header) + req.Header.Set("Mcp-Session-Id", c.sessionID) // ← SAME session ID + + fmt.Printf("\n✓ HTTP POST #3 - Call tool (upgraded scopes)\n") + fmt.Printf(" Request Header: Authorization: Bearer %s ← DIFFERENT TOKEN\n", newToken[:30]+"...") + fmt.Printf(" Request Header: Mcp-Session-Id: %s ← SAME SESSION\n", c.sessionID) + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() //nolint:errcheck + + fmt.Printf(" Response: %d OK\n", resp.StatusCode) + fmt.Printf(" Response Body: JSON-RPC Success\n") + + return nil +} + +func toReader(v interface{}) io.Reader { + b, _ := json.Marshal(v) + return bytes.NewReader(b) +} + +// ExampleAuthorizationFlow demonstrates the complete flow +func ExampleAuthorizationFlow() { + client := &MCPClient{ + serverURL: "http://localhost:3000/mcp", + httpClient: &http.Client{}, + } + + ctx := context.Background() + + // Step 1: Initialize with limited scopes + initialToken := "token-with-scopes-mcp:tools:read" + client.Initialize(ctx, initialToken) //nolint:errcheck + + // Step 2: Try to call write operation (will fail) + err := client.CallToolWithLimitedScopes(ctx, initialToken) + + // Step 3: Get new token with required scopes + if err != nil { + newToken := client.ObtainNewToken([]string{"mcp:tools:write"}) + + // Step 4: Retry with upgraded token (same session!) + _ = client.CallToolWithUpgradedToken(ctx, newToken) + } + + fmt.Printf("\n=== Summary ===\n") + fmt.Printf("• Session persists via Mcp-Session-Id HTTP header\n") + fmt.Printf("• Authorization changes via Authorization HTTP header\n") + fmt.Printf("• Each JSON-RPC request is a separate HTTP POST\n") + fmt.Printf("• HTTP headers carry auth/session, not JSON-RPC payload\n") +} + +/* +Expected Output: + +✓ HTTP POST #1 - Initialize + Request Header: Authorization: Bearer token-with-scopes-mc... + Response Header: Mcp-Session-Id: abc-123-def-456 + +✓ HTTP POST #2 - Call tool (limited scopes) + Request Header: Authorization: Bearer token-with-scopes-mc... + Request Header: Mcp-Session-Id: abc-123-def-456 + Response Body: JSON-RPC Error + { + "error": { + "code": -32001, + "message": "Insufficient permissions", + "data": { + "required_scopes": [mcp:tools:write] + } + } + } + +✓ OAuth Flow - Obtained new token + Scopes: [mcp:tools:write] + New Token: new-token-with-scopes-[mcp:too... + +✓ HTTP POST #3 - Call tool (upgraded scopes) + Request Header: Authorization: Bearer new-token-with-scopes-[mcp:too... ← DIFFERENT TOKEN + Request Header: Mcp-Session-Id: abc-123-def-456 ← SAME SESSION + Response: 200 OK + Response Body: JSON-RPC Success + +=== Summary === +• Session persists via Mcp-Session-Id HTTP header +• Authorization changes via Authorization HTTP header +• Each JSON-RPC request is a separate HTTP POST +• HTTP headers carry auth/session, not JSON-RPC payload +*/ diff --git a/router-tests/mcp_oauth_e2e_test.go b/router-tests/mcp_oauth_e2e_test.go new file mode 100644 index 0000000000..950f1a0c01 --- /dev/null +++ b/router-tests/mcp_oauth_e2e_test.go @@ -0,0 +1,283 @@ +package integration + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/wundergraph/cosmo/router-tests/testenv" + "github.com/wundergraph/cosmo/router-tests/testutil" + "github.com/wundergraph/cosmo/router/pkg/config" +) + +// TestMCPOAuthScopeUpgrade tests the complete OAuth scope upgrade flow with real JWT validation. +// Uses OAuthTestServer which provides a full OAuth 2.1 AS (JWKS + token endpoint + registration) +// so the same server can be used by both Go tests and the official MCP TypeScript SDK. +func TestMCPOAuthScopeUpgrade(t *testing.T) { + oauthServer, err := testutil.NewOAuthTestServer(t, nil) + require.NoError(t, err, "failed to start OAuth server") + defer oauthServer.Close() //nolint:errcheck + + readOnlyToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:tools:read"}) + require.NoError(t, err, "failed to create read-only token") + + testenv.Run(t, &testenv.Config{ + MCP: config.MCPConfiguration{ + Enabled: true, + ExposeSchema: true, + EnableArbitraryOperations: true, + OAuth: config.MCPOAuthConfiguration{ + Enabled: true, + JWKS: []config.JWKSConfiguration{ + {URL: oauthServer.JWKSURL()}, + }, + AuthorizationServerURL: oauthServer.Issuer(), + Scopes: config.MCPOAuthScopesConfiguration{}, + }, + }, + MCPAuthToken: readOnlyToken, + }, func(t *testing.T, xEnv *testenv.Environment) { + ctx := context.Background() + + client := NewMCPAuthClient(xEnv.GetMCPServerAddr(), readOnlyToken) + err = client.Connect(ctx) + require.NoError(t, err, "should connect with valid token") + defer client.Close() //nolint:errcheck + + t.Log("Connected with read-only token") + + result, err := client.CallTool(ctx, "get_schema", nil) + require.NoError(t, err, "get_schema should succeed with valid token") + require.NotNil(t, result) + t.Log("Tool call succeeded with initial token") + + newToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:tools:read", "mcp:tools:write"}) + require.NoError(t, err, "failed to create new token") + + client.SetToken(newToken) + t.Log("Updated to new token (same session)") + + result, err = client.CallTool(ctx, "execute_graphql", map[string]any{ + "query": "query { employees { id } }", + }) + require.NoError(t, err, "tool call should succeed after token change") + require.NotNil(t, result) + t.Log("Tool call succeeded with new token") + + anotherToken, err := oauthServer.CreateTokenWithScopes("different-user", []string{"mcp:admin"}) + require.NoError(t, err, "failed to create another token") + + client.SetToken(anotherToken) + _, err = client.CallTool(ctx, "get_schema", nil) + require.NoError(t, err, "should succeed after second token change") + t.Log("Multiple token changes work on same session") + }) +} + +// TestMCPOAuthInvalidToken tests that invalid JWT tokens are rejected with HTTP 401. +func TestMCPOAuthInvalidToken(t *testing.T) { + oauthServer, err := testutil.NewOAuthTestServer(t, nil) + require.NoError(t, err, "failed to start OAuth server") + defer oauthServer.Close() //nolint:errcheck + + validToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:tools:read"}) + require.NoError(t, err, "failed to create valid token") + + testenv.Run(t, &testenv.Config{ + MCP: config.MCPConfiguration{ + Enabled: true, + OAuth: config.MCPOAuthConfiguration{ + Enabled: true, + JWKS: []config.JWKSConfiguration{ + {URL: oauthServer.JWKSURL()}, + }, + AuthorizationServerURL: oauthServer.Issuer(), + }, + }, + MCPAuthToken: validToken, + }, func(t *testing.T, xEnv *testenv.Environment) { + ctx := context.Background() + + client := NewMCPAuthClient(xEnv.GetMCPServerAddr(), "invalid-jwt-token") + + err := client.Connect(ctx) + require.Error(t, err, "should fail to connect with invalid token") + + authErr, ok := err.(*AuthError) + if ok { + assert.Equal(t, http.StatusUnauthorized, authErr.StatusCode, "should return HTTP 401") + assert.NotEmpty(t, authErr.ResourceMetadataURL, "should include resource_metadata for OAuth discovery") + t.Logf("Invalid token rejected with HTTP 401: %v", authErr) + } + }) +} + +// TestMCPOAuthMissingToken tests that missing Authorization header is rejected. +func TestMCPOAuthMissingToken(t *testing.T) { + oauthServer, err := testutil.NewOAuthTestServer(t, nil) + require.NoError(t, err, "failed to start OAuth server") + defer oauthServer.Close() //nolint:errcheck + + validToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:tools:read"}) + require.NoError(t, err, "failed to create valid token") + + testenv.Run(t, &testenv.Config{ + MCP: config.MCPConfiguration{ + Enabled: true, + OAuth: config.MCPOAuthConfiguration{ + Enabled: true, + JWKS: []config.JWKSConfiguration{ + {URL: oauthServer.JWKSURL()}, + }, + AuthorizationServerURL: oauthServer.Issuer(), + }, + }, + MCPAuthToken: validToken, + }, func(t *testing.T, xEnv *testenv.Environment) { + ctx := context.Background() + + client := NewMCPAuthClient(xEnv.GetMCPServerAddr(), "") + + err := client.Connect(ctx) + require.Error(t, err, "should fail to connect without token") + + authErr, ok := err.(*AuthError) + if ok { + assert.Equal(t, http.StatusUnauthorized, authErr.StatusCode, "should return HTTP 401") + assert.NotEmpty(t, authErr.ResourceMetadataURL, "should include resource_metadata for OAuth discovery") + t.Logf("Request without token rejected with HTTP 401: %v", authErr) + } + }) +} + +// TestMCPOAuthPerToolScopes tests per-tool scope requirements. +func TestMCPOAuthPerToolScopes(t *testing.T) { + oauthServer, err := testutil.NewOAuthTestServer(t, nil) + require.NoError(t, err, "failed to start OAuth server") + defer oauthServer.Close() //nolint:errcheck + + initToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:connect"}) + require.NoError(t, err, "failed to create init token") + + testenv.Run(t, &testenv.Config{ + MCP: config.MCPConfiguration{ + Enabled: true, + ExposeSchema: true, + EnableArbitraryOperations: true, + OAuth: config.MCPOAuthConfiguration{ + Enabled: true, + JWKS: []config.JWKSConfiguration{ + {URL: oauthServer.JWKSURL()}, + }, + AuthorizationServerURL: oauthServer.Issuer(), + Scopes: config.MCPOAuthScopesConfiguration{ + Initialize: []string{"mcp:connect"}, + ToolsCall: []string{"mcp:tools:write"}, + }, + ScopeChallengeMode: "required_and_existing", + }, + }, + MCPAuthToken: initToken, + }, func(t *testing.T, xEnv *testenv.Environment) { + ctx := context.Background() + + t.Run("HTTP-level scopes are enforced on all requests", func(t *testing.T) { + noConnectToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:tools:read"}) + require.NoError(t, err) + + client := NewMCPAuthClient(xEnv.GetMCPServerAddr(), noConnectToken) + err = client.Connect(ctx) + require.Error(t, err, "should fail to connect without HTTP-level scopes") + + authErr, ok := err.(*AuthError) + if ok { + assert.True(t, authErr.StatusCode == http.StatusUnauthorized || authErr.StatusCode == http.StatusForbidden) + t.Logf("HTTP-level scope enforcement: %v", authErr) + } + }) + + t.Run("Per-tool scopes are enforced on tool calls", func(t *testing.T) { + connectOnlyToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:connect"}) + require.NoError(t, err) + + client := NewMCPAuthClient(xEnv.GetMCPServerAddr(), connectOnlyToken) + err = client.Connect(ctx) + require.NoError(t, err, "should connect with HTTP-level scopes") + defer client.Close() //nolint:errcheck + + _, err = client.CallTool(ctx, "get_schema", nil) + require.Error(t, err, "should fail without per-tool scopes") + + authErr, ok := err.(*AuthError) + require.True(t, ok, "should return AuthError") + assert.Equal(t, http.StatusForbidden, authErr.StatusCode, "should return HTTP 403") + assert.Equal(t, "insufficient_scope", authErr.ErrorCode) + assert.Contains(t, authErr.RequiredScopes, "mcp:tools:read") + }) + + t.Run("Token with correct per-tool scopes succeeds", func(t *testing.T) { + readToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:connect", "mcp:tools:read"}) + require.NoError(t, err) + + client := NewMCPAuthClient(xEnv.GetMCPServerAddr(), readToken) + err = client.Connect(ctx) + require.NoError(t, err) + defer client.Close() //nolint:errcheck + + result, err := client.CallTool(ctx, "get_schema", nil) + require.NoError(t, err, "should succeed with correct scopes") + require.NotNil(t, result) + }) + + t.Run("Different tools require different scopes", func(t *testing.T) { + readToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:connect", "mcp:tools:read"}) + require.NoError(t, err) + + client := NewMCPAuthClient(xEnv.GetMCPServerAddr(), readToken) + err = client.Connect(ctx) + require.NoError(t, err) + defer client.Close() //nolint:errcheck + + _, err = client.CallTool(ctx, "get_schema", nil) + require.NoError(t, err, "read tool should succeed") + + _, err = client.CallTool(ctx, "execute_graphql", map[string]any{ + "query": "query { __typename }", + }) + require.Error(t, err, "write tool should fail without write scopes") + + authErr, ok := err.(*AuthError) + require.True(t, ok) + assert.Equal(t, http.StatusForbidden, authErr.StatusCode) + assert.Contains(t, authErr.RequiredScopes, "mcp:tools:write") + }) + + t.Run("Scope upgrade on same session works", func(t *testing.T) { + readToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:connect", "mcp:tools:read"}) + require.NoError(t, err) + + client := NewMCPAuthClient(xEnv.GetMCPServerAddr(), readToken) + err = client.Connect(ctx) + require.NoError(t, err) + defer client.Close() //nolint:errcheck + + _, err = client.CallTool(ctx, "execute_graphql", map[string]any{ + "query": "query { __typename }", + }) + require.Error(t, err, "should fail without write scopes") + + writeToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:connect", "mcp:tools:read", "mcp:tools:write"}) + require.NoError(t, err) + + client.SetToken(writeToken) + + result, err := client.CallTool(ctx, "execute_graphql", map[string]any{ + "query": "query { __typename }", + }) + require.NoError(t, err, "should succeed after scope upgrade") + require.NotNil(t, result) + }) + }) +} \ No newline at end of file diff --git a/router-tests/protocol/mcp_test.go b/router-tests/protocol/mcp_test.go index ac9bac48d5..04d96a5109 100644 --- a/router-tests/protocol/mcp_test.go +++ b/router-tests/protocol/mcp_test.go @@ -473,7 +473,7 @@ func TestMCP(t *testing.T) { // Make the request resp, err := xEnv.RouterClient.Do(req) require.NoError(t, err) - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck // Verify response status assert.Equal(t, http.StatusNoContent, resp.StatusCode) @@ -530,7 +530,7 @@ func TestMCP(t *testing.T) { // Make the request resp, err := xEnv.RouterClient.Do(req) require.NoError(t, err) - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck // Verify CORS headers are present in the response assert.Equal(t, "*", resp.Header.Get("Access-Control-Allow-Origin")) @@ -564,7 +564,7 @@ func TestMCP(t *testing.T) { // Make the request resp, err := xEnv.RouterClient.Do(req) require.NoError(t, err) - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck // Verify CORS headers are present in the response assert.Equal(t, "*", resp.Header.Get("Access-Control-Allow-Origin")) @@ -602,7 +602,7 @@ func TestMCP(t *testing.T) { // Make the request resp, err := xEnv.RouterClient.Do(req) require.NoError(t, err) - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck // Verify CORS headers are present assert.Equal(t, "*", resp.Header.Get("Access-Control-Allow-Origin")) @@ -643,7 +643,7 @@ func TestMCP(t *testing.T) { // Make the request resp, err := xEnv.RouterClient.Do(req) require.NoError(t, err) - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck // Verify CORS headers are present in the response assert.Equal(t, "*", resp.Header.Get("Access-Control-Allow-Origin")) @@ -947,7 +947,7 @@ input UserInput { // Make the request resp, err := xEnv.RouterClient.Do(req) require.NoError(t, err) - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck // With stateless mode, the request should succeed t.Logf("Response Status: %d", resp.StatusCode) @@ -1053,7 +1053,7 @@ input UserInput { resp, err := xEnv.RouterClient.Do(req) require.NoError(t, err) - defer resp.Body.Close() + defer resp.Body.Close() //nolint:errcheck if resp.StatusCode != http.StatusOK { t.Logf("Response Status: %d", resp.StatusCode) diff --git a/router-tests/testenv/testenv.go b/router-tests/testenv/testenv.go index 93da790b38..28f25a8597 100644 --- a/router-tests/testenv/testenv.go +++ b/router-tests/testenv/testenv.go @@ -31,6 +31,7 @@ import ( "github.com/cloudflare/backoff" mcpclient "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/client/transport" "github.com/mark3labs/mcp-go/mcp" "github.com/golang-jwt/jwt/v5" @@ -365,6 +366,7 @@ type Config struct { NoShutdownTestServer bool MCP config.MCPConfiguration MCPOperationsPath string + MCPAuthToken string // Optional Bearer token for MCP authentication EnableRedis bool EnableRedisCluster bool Plugins PluginConfig @@ -851,7 +853,17 @@ func CreateTestSupervisorEnv(t testing.TB, cfg *Config) (*Environment, error) { if cfg.MCP.Enabled { // Create MCP client connecting to the MCP server mcpAddr := fmt.Sprintf("http://%s/mcp", cfg.MCP.Server.ListenAddr) - client, err := mcpclient.NewStreamableHttpClient(mcpAddr) + + // Add authentication headers if token is provided + var clientOpts []transport.StreamableHTTPCOption + if cfg.MCPAuthToken != "" { + headers := map[string]string{ + "Authorization": fmt.Sprintf("Bearer %s", cfg.MCPAuthToken), + } + clientOpts = append(clientOpts, transport.WithHTTPHeaders(headers)) + } + + client, err := mcpclient.NewStreamableHttpClient(mcpAddr, clientOpts...) if err != nil { t.Fatalf("Failed to create MCP client: %v", err) } @@ -1279,7 +1291,17 @@ func CreateTestEnv(t testing.TB, cfg *Config) (*Environment, error) { if cfg.MCP.Enabled { // Create MCP client connecting to the MCP server mcpAddr := fmt.Sprintf("http://%s/mcp", cfg.MCP.Server.ListenAddr) - client, err := mcpclient.NewStreamableHttpClient(mcpAddr) + + // Add authentication headers if token is provided + var clientOpts []transport.StreamableHTTPCOption + if cfg.MCPAuthToken != "" { + headers := map[string]string{ + "Authorization": fmt.Sprintf("Bearer %s", cfg.MCPAuthToken), + } + clientOpts = append(clientOpts, transport.WithHTTPHeaders(headers)) + } + + client, err := mcpclient.NewStreamableHttpClient(mcpAddr, clientOpts...) if err != nil { t.Fatalf("Failed to create MCP client: %v", err) } diff --git a/router-tests/testutil/auth_helpers.go b/router-tests/testutil/auth_helpers.go new file mode 100644 index 0000000000..391bf0c52a --- /dev/null +++ b/router-tests/testutil/auth_helpers.go @@ -0,0 +1,63 @@ +package testutil + +import ( + "strings" +) + +// ParseWWWAuthenticateParams parses the WWW-Authenticate header from HTTP responses. +// This is a simple parser for test validation only, not production use. +// +// NOTE: LLM-generated - there are no well-established Go libraries for parsing +// WWW-Authenticate response headers (as of 2026). This parser handles the +// common case of Bearer authentication with quoted parameter values. +// +// Example input: `Bearer error="insufficient_scope", scope="read write", resource_metadata="https://example.com"` +// Example output: map[string]string{"error": "insufficient_scope", "scope": "read write", "resource_metadata": "https://example.com"} +func ParseWWWAuthenticateParams(header string) map[string]string { + params := make(map[string]string) + + // Remove "Bearer " prefix (case-insensitive) + if len(header) >= 7 && strings.EqualFold(header[:7], "Bearer ") { + header = header[7:] + } + header = strings.TrimSpace(header) + + // Simple state machine to parse key="value" pairs + var key, value strings.Builder + inKey := true + inQuote := false + + for i := 0; i < len(header); i++ { + ch := header[i] + + switch { + case ch == '=' && inKey: + inKey = false + case ch == '"' && !inKey: + // Track quote state but don't add quotes to value + inQuote = !inQuote + case ch == ',' && !inQuote: + if key.Len() > 0 { + params[strings.TrimSpace(key.String())] = strings.TrimSpace(value.String()) + } + key.Reset() + value.Reset() + inKey = true + case inKey: + key.WriteByte(ch) + default: + // We're in a value (!inKey) and ch is not a quote (already handled above) + // Include everything (including spaces) when inside quotes + if inQuote || ch != ' ' || value.Len() > 0 { + value.WriteByte(ch) + } + } + } + + // Add final pair + if key.Len() > 0 { + params[strings.TrimSpace(key.String())] = strings.TrimSpace(value.String()) + } + + return params +} diff --git a/router-tests/testutil/jwt_helper.go b/router-tests/testutil/jwt_helper.go new file mode 100644 index 0000000000..99b1929d62 --- /dev/null +++ b/router-tests/testutil/jwt_helper.go @@ -0,0 +1,195 @@ +package testutil + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/MicahParks/jwkset" + "github.com/golang-jwt/jwt/v5" + "github.com/wundergraph/cosmo/router-tests/freeport" + "github.com/wundergraph/cosmo/router-tests/jwks" +) + +// JWKSTestServer provides JWT token generation for testing +type JWKSTestServer struct { + t *testing.T + provider jwks.Crypto + keyID string + issuer string + audience string + jwksURL string + server *http.Server + storage jwkset.Storage +} + +// NewJWKSTestServer creates a new JWKS test server with RSA keys +// The server will automatically allocate a free port and return it when the test ends +func NewJWKSTestServer(t *testing.T) (*JWKSTestServer, error) { + t.Helper() + + // Get a free port using the freeport package + port := freeport.GetOne(t) + portStr := fmt.Sprintf("%d", port) + + keyID := "test_rsa" + provider, err := jwks.NewRSACrypto(keyID, jwkset.AlgRS256, 2048) + if err != nil { + return nil, fmt.Errorf("failed to create RSA crypto: %w", err) + } + + storage := jwkset.NewMemoryStorage() + ctx := context.Background() + + jwk, err := provider.MarshalJWK() + if err != nil { + return nil, fmt.Errorf("failed to marshal JWK: %w", err) + } + + if err := storage.KeyWrite(ctx, jwk); err != nil { + return nil, fmt.Errorf("failed to write key to storage: %w", err) + } + + server := &JWKSTestServer{ + t: t, + provider: provider, + keyID: keyID, + issuer: fmt.Sprintf("http://localhost:%s", portStr), + audience: "test-audience", + jwksURL: fmt.Sprintf("http://localhost:%s/.well-known/jwks.json", portStr), + storage: storage, + } + + // Start HTTP server + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/jwks.json", server.handleJWKS) + + httpServer := &http.Server{ + Addr: ":" + portStr, + Handler: mux, + } + + server.server = httpServer + + go func() { + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + t.Logf("JWKS server error: %v", err) + } + }() + + // Wait for server to start + if err := server.waitForReady(5 * time.Second); err != nil { + return nil, fmt.Errorf("JWKS server failed to start: %w", err) + } + + t.Logf("JWKS test server started at %s", server.issuer) + + return server, nil +} + +// waitForReady waits for the server to be ready +func (s *JWKSTestServer) waitForReady(timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("timeout waiting for JWKS server") + case <-ticker.C: + req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.jwksURL, nil) + if err != nil { + continue + } + resp, err := http.DefaultClient.Do(req) + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + } + } +} + +// handleJWKS serves the JWKS JSON +func (s *JWKSTestServer) handleJWKS(w http.ResponseWriter, r *http.Request) { + ctx := context.Background() + rawJWKS, err := s.storage.JSON(ctx) + if err != nil { + s.t.Logf("Failed to get JWKS: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(rawJWKS) +} + +// CreateToken creates a JWT token with the specified claims +// Default claims (iss, aud, iat, exp) are added automatically +func (s *JWKSTestServer) CreateToken(claims map[string]any) (string, error) { + s.t.Helper() + + now := time.Now() + tokenClaims := jwt.MapClaims{ + "iss": s.issuer, + "aud": s.audience, + "iat": now.Unix(), + "exp": now.Add(1 * time.Hour).Unix(), + } + + // Merge custom claims + for k, v := range claims { + tokenClaims[k] = v + } + + token := jwt.NewWithClaims(s.provider.SigningMethod(), tokenClaims) + token.Header[jwkset.HeaderKID] = s.keyID + + signed, err := token.SignedString(s.provider.PrivateKey()) + if err != nil { + return "", fmt.Errorf("failed to sign token: %w", err) + } + + return signed, nil +} + +// CreateTokenWithScopes creates a token with specific OAuth scopes +func (s *JWKSTestServer) CreateTokenWithScopes(sub string, scopes []string) (string, error) { + s.t.Helper() + + scopeStr := "" + if len(scopes) > 0 { + scopeStr = scopes[0] + for i := 1; i < len(scopes); i++ { + scopeStr += " " + scopes[i] + } + } + + return s.CreateToken(map[string]any{ + "sub": sub, + "scope": scopeStr, + }) +} + +// JWKSURL returns the URL of the JWKS endpoint +func (s *JWKSTestServer) JWKSURL() string { + return s.jwksURL +} + +// Issuer returns the issuer URL +func (s *JWKSTestServer) Issuer() string { + return s.issuer +} + +// Close stops the JWKS server +func (s *JWKSTestServer) Close() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return s.server.Shutdown(ctx) +} diff --git a/router-tests/testutil/oauth_server.go b/router-tests/testutil/oauth_server.go new file mode 100644 index 0000000000..ea8353553c --- /dev/null +++ b/router-tests/testutil/oauth_server.go @@ -0,0 +1,544 @@ +package testutil + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "testing" + "time" + + "github.com/MicahParks/jwkset" + "github.com/golang-jwt/jwt/v5" + "github.com/wundergraph/cosmo/router-tests/freeport" + "github.com/wundergraph/cosmo/router-tests/jwks" +) + +// OAuthClient represents a registered OAuth client. +type OAuthClient struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + GrantTypes []string `json:"grant_types"` + Scope string `json:"scope,omitempty"` +} + +// authCode is a pending authorization code waiting to be exchanged. +type authCode struct { + clientID string + scope string + redirectURI string + createdAt time.Time +} + +// OAuthTestServer is a minimal OAuth 2.1 Authorization Server for integration tests. +// +// Supported endpoints: +// - GET /.well-known/jwks.json — JWKS for token verification +// - GET /.well-known/oauth-authorization-server — AS metadata (RFC 8414) +// - POST /token — Token endpoint (client_credentials + authorization_code) +// - POST /register — Dynamic client registration (RFC 7591) +// - GET /authorize — Authorization endpoint (auto-approves for testing) +// +// The server issues real signed JWTs that can be validated by any consumer +// fetching the JWKS endpoint, making it suitable for end-to-end testing with +// the official MCP TypeScript SDK's ClientCredentialsProvider. +type OAuthTestServer struct { + t *testing.T + provider jwks.Crypto + keyID string + issuer string + audience string + jwksURL string + server *http.Server + storage jwkset.Storage + + mu sync.RWMutex + clients map[string]*OAuthClient // clientID → client + codes map[string]*authCode // code → pending auth code + + // DefaultScopes assigned to tokens when the client doesn't request specific scopes. + DefaultScopes string +} + +// OAuthTestServerOptions configures the test OAuth server. +type OAuthTestServerOptions struct { + DefaultScopes string + PreRegisteredClients []*OAuthClient +} + +// NewOAuthTestServer creates and starts a minimal OAuth 2.1 AS on a random port. +func NewOAuthTestServer(t *testing.T, opts *OAuthTestServerOptions) (*OAuthTestServer, error) { + t.Helper() + + if opts == nil { + opts = &OAuthTestServerOptions{} + } + + port := freeport.GetOne(t) + portStr := fmt.Sprintf("%d", port) + + cryptoProvider, err := jwks.NewRSACrypto("test_rsa", jwkset.AlgRS256, 2048) + if err != nil { + return nil, fmt.Errorf("failed to create RSA crypto: %w", err) + } + + jwkStorage := jwkset.NewMemoryStorage() + jwk, err := cryptoProvider.MarshalJWK() + if err != nil { + return nil, fmt.Errorf("failed to marshal JWK: %w", err) + } + if err := jwkStorage.KeyWrite(context.Background(), jwk); err != nil { + return nil, fmt.Errorf("failed to write key to storage: %w", err) + } + + baseURL := fmt.Sprintf("http://localhost:%s", portStr) + + s := &OAuthTestServer{ + t: t, + provider: cryptoProvider, + keyID: "test_rsa", + issuer: baseURL, + audience: "test-audience", + jwksURL: baseURL + "/.well-known/jwks.json", + storage: jwkStorage, + clients: make(map[string]*OAuthClient), + codes: make(map[string]*authCode), + DefaultScopes: opts.DefaultScopes, + } + + for _, c := range opts.PreRegisteredClients { + s.clients[c.ClientID] = c + } + + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/jwks.json", s.handleJWKS) + mux.HandleFunc("/.well-known/oauth-authorization-server", s.handleASMetadata) + mux.HandleFunc("/token", s.handleToken) + mux.HandleFunc("/register", s.handleRegister) + mux.HandleFunc("/authorize", s.handleAuthorize) + + httpServer := &http.Server{ + Addr: ":" + portStr, + Handler: withCORS(mux), + } + s.server = httpServer + + go func() { + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + t.Logf("OAuth server error: %v", err) + } + }() + + if err := s.waitForReady(5 * time.Second); err != nil { + return nil, fmt.Errorf("OAuth server failed to start: %w", err) + } + + t.Logf("OAuth test server started at %s", s.issuer) + return s, nil +} + +// --------------------------------------------------------------------------- +// Endpoints +// --------------------------------------------------------------------------- + +func (s *OAuthTestServer) handleJWKS(w http.ResponseWriter, _ *http.Request) { + rawJWKS, err := s.storage.JSON(context.Background()) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(rawJWKS) +} + +// handleASMetadata serves RFC 8414 Authorization Server Metadata. +func (s *OAuthTestServer) handleASMetadata(w http.ResponseWriter, _ *http.Request) { + meta := map[string]any{ + "issuer": s.issuer, + "token_endpoint": s.issuer + "/token", + "authorization_endpoint": s.issuer + "/authorize", + "registration_endpoint": s.issuer + "/register", + "jwks_uri": s.jwksURL, + "response_types_supported": []string{"code"}, + "grant_types_supported": []string{"client_credentials", "authorization_code"}, + "token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"}, + "code_challenge_methods_supported": []string{"S256"}, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(meta) +} + +// handleToken handles client_credentials and authorization_code grants. +func (s *OAuthTestServer) handleToken(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + s.tokenError(w, "invalid_request", "POST required", http.StatusMethodNotAllowed) + return + } + if err := r.ParseForm(); err != nil { + s.tokenError(w, "invalid_request", "bad form body", http.StatusBadRequest) + return + } + + switch r.FormValue("grant_type") { + case "client_credentials": + s.handleClientCredentials(w, r) + case "authorization_code": + s.handleAuthorizationCodeExchange(w, r) + default: + s.tokenError(w, "unsupported_grant_type", + fmt.Sprintf("unsupported grant_type %q", r.FormValue("grant_type")), + http.StatusBadRequest) + } +} + +func (s *OAuthTestServer) handleClientCredentials(w http.ResponseWriter, r *http.Request) { + clientID, clientSecret, ok := s.authenticateClient(r) + if !ok { + s.tokenError(w, "invalid_client", "client authentication failed", http.StatusUnauthorized) + return + } + + s.mu.RLock() + client, exists := s.clients[clientID] + s.mu.RUnlock() + + if !exists || client.ClientSecret != clientSecret { + s.tokenError(w, "invalid_client", "unknown client or bad secret", http.StatusUnauthorized) + return + } + + scope := r.FormValue("scope") + if scope == "" { + scope = client.Scope + } + if scope == "" { + scope = s.DefaultScopes + } + + s.issueTokenResponse(w, clientID, scope) +} + +func (s *OAuthTestServer) handleAuthorizationCodeExchange(w http.ResponseWriter, r *http.Request) { + code := r.FormValue("code") + if code == "" { + s.tokenError(w, "invalid_request", "missing code", http.StatusBadRequest) + return + } + + s.mu.Lock() + pending, exists := s.codes[code] + if exists { + delete(s.codes, code) // one-time use + } + s.mu.Unlock() + + if !exists { + s.tokenError(w, "invalid_grant", "unknown or expired code", http.StatusBadRequest) + return + } + + // Codes expire after 60 seconds + if time.Since(pending.createdAt) > 60*time.Second { + s.tokenError(w, "invalid_grant", "code expired", http.StatusBadRequest) + return + } + + // Authenticate the client + clientID, clientSecret, ok := s.authenticateClient(r) + if !ok { + s.tokenError(w, "invalid_client", "client authentication failed", http.StatusUnauthorized) + return + } + + s.mu.RLock() + client, clientExists := s.clients[clientID] + s.mu.RUnlock() + + if !clientExists || client.ClientSecret != clientSecret { + s.tokenError(w, "invalid_client", "unknown client or bad secret", http.StatusUnauthorized) + return + } + + if pending.clientID != clientID { + s.tokenError(w, "invalid_grant", "code was issued to a different client", http.StatusBadRequest) + return + } + + s.issueTokenResponse(w, clientID, pending.scope) +} + +// handleAuthorize is a simplified authorization endpoint that auto-approves. +// For interactive testing it returns a minimal HTML page; for automated tests +// it immediately redirects with a code. +func (s *OAuthTestServer) handleAuthorize(w http.ResponseWriter, r *http.Request) { + clientID := r.URL.Query().Get("client_id") + redirectURI := r.URL.Query().Get("redirect_uri") + scope := r.URL.Query().Get("scope") + + if clientID == "" || redirectURI == "" { + http.Error(w, "missing client_id or redirect_uri", http.StatusBadRequest) + return + } + + // Generate authorization code + code := randomString(32) + + s.mu.Lock() + s.codes[code] = &authCode{ + clientID: clientID, + scope: scope, + redirectURI: redirectURI, + createdAt: time.Now(), + } + s.mu.Unlock() + + // Preserve the state parameter for PKCE / CSRF + state := r.URL.Query().Get("state") + location := fmt.Sprintf("%s?code=%s", redirectURI, code) + if state != "" { + location += "&state=" + state + } + + http.Redirect(w, r, location, http.StatusFound) +} + +// handleRegister implements RFC 7591 Dynamic Client Registration. +func (s *OAuthTestServer) handleRegister(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST required", http.StatusMethodNotAllowed) + return + } + + var req struct { + ClientName string `json:"client_name"` + GrantTypes []string `json:"grant_types"` + RedirectURIs []string `json:"redirect_uris"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + Scope string `json:"scope"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad JSON", http.StatusBadRequest) + return + } + + clientID := "dyn-" + randomString(16) + clientSecret := "secret-" + randomString(24) + + client := &OAuthClient{ + ClientID: clientID, + ClientSecret: clientSecret, + GrantTypes: req.GrantTypes, + Scope: req.Scope, + } + + s.mu.Lock() + s.clients[clientID] = client + s.mu.Unlock() + + resp := map[string]any{ + "client_id": clientID, + "client_secret": clientSecret, + "client_name": req.ClientName, + "grant_types": req.GrantTypes, + "redirect_uris": req.RedirectURIs, + "token_endpoint_auth_method": req.TokenEndpointAuthMethod, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(resp) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// authenticateClient extracts client credentials via Basic auth or POST body. +func (s *OAuthTestServer) authenticateClient(r *http.Request) (clientID, clientSecret string, ok bool) { + // client_secret_basic + if authHeader := r.Header.Get("Authorization"); strings.HasPrefix(authHeader, "Basic ") { + decoded, err := base64.StdEncoding.DecodeString(authHeader[6:]) + if err == nil { + if parts := strings.SplitN(string(decoded), ":", 2); len(parts) == 2 { + return parts[0], parts[1], true + } + } + } + + // client_secret_post + id, secret := r.FormValue("client_id"), r.FormValue("client_secret") + if id != "" && secret != "" { + return id, secret, true + } + + return "", "", false +} + +func (s *OAuthTestServer) issueTokenResponse(w http.ResponseWriter, sub, scope string) { + now := time.Now() + claims := jwt.MapClaims{ + "iss": s.issuer, + "aud": s.audience, + "sub": sub, + "iat": now.Unix(), + "exp": now.Add(1 * time.Hour).Unix(), + "client_id": sub, + } + if scope != "" { + claims["scope"] = scope + } + + token := jwt.NewWithClaims(s.provider.SigningMethod(), claims) + token.Header[jwkset.HeaderKID] = s.keyID + + accessToken, err := token.SignedString(s.provider.PrivateKey()) + if err != nil { + s.t.Logf("Failed to sign token: %v", err) + s.tokenError(w, "server_error", "token signing failed", http.StatusInternalServerError) + return + } + + resp := map[string]any{ + "access_token": accessToken, + "token_type": "Bearer", + "expires_in": 3600, + } + if scope != "" { + resp["scope"] = scope + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(resp) +} + +func (s *OAuthTestServer) tokenError(w http.ResponseWriter, errCode, description string, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": errCode, + "error_description": description, + }) +} + +func (s *OAuthTestServer) waitForReady(timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("timeout waiting for OAuth server") + case <-ticker.C: + req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.jwksURL, nil) + if err != nil { + continue + } + resp, err := http.DefaultClient.Do(req) + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Public API for tests +// --------------------------------------------------------------------------- + +// CreateToken creates a signed JWT with the given claims (for direct test use). +func (s *OAuthTestServer) CreateToken(claims map[string]any) (string, error) { + s.t.Helper() + + now := time.Now() + tokenClaims := jwt.MapClaims{ + "iss": s.issuer, + "aud": s.audience, + "iat": now.Unix(), + "exp": now.Add(1 * time.Hour).Unix(), + } + for k, v := range claims { + tokenClaims[k] = v + } + + token := jwt.NewWithClaims(s.provider.SigningMethod(), tokenClaims) + token.Header[jwkset.HeaderKID] = s.keyID + + return token.SignedString(s.provider.PrivateKey()) +} + +// CreateTokenWithScopes creates a signed JWT with specific OAuth scopes. +func (s *OAuthTestServer) CreateTokenWithScopes(sub string, scopes []string) (string, error) { + s.t.Helper() + return s.CreateToken(map[string]any{ + "sub": sub, + "scope": strings.Join(scopes, " "), + }) +} + +// RegisterClient pre-registers a client (bypass dynamic registration). +func (s *OAuthTestServer) RegisterClient(clientID, clientSecret, scope string) *OAuthClient { + client := &OAuthClient{ + ClientID: clientID, + ClientSecret: clientSecret, + GrantTypes: []string{"client_credentials"}, + Scope: scope, + } + s.mu.Lock() + s.clients[clientID] = client + s.mu.Unlock() + return client +} + +// JWKSURL returns the JWKS endpoint URL. +func (s *OAuthTestServer) JWKSURL() string { return s.jwksURL } + +// Issuer returns the base URL / issuer of the OAuth server. +func (s *OAuthTestServer) Issuer() string { return s.issuer } + +// TokenEndpoint returns the token endpoint URL. +func (s *OAuthTestServer) TokenEndpoint() string { return s.issuer + "/token" } + +// Close stops the server. +func (s *OAuthTestServer) Close() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return s.server.Shutdown(ctx) +} + +func randomString(nBytes int) string { + b := make([]byte, nBytes) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// withCORS wraps an http.Handler with permissive CORS headers for browser-based +// MCP clients (e.g. MCP Inspector). This is required because the TypeScript SDK +// fetches /.well-known/oauth-authorization-server cross-origin from the browser. +func withCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept, MCP-Protocol-Version") + w.Header().Set("Access-Control-Max-Age", "86400") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} \ No newline at end of file diff --git a/router-tests/testutil/oauth_server_test.go b/router-tests/testutil/oauth_server_test.go new file mode 100644 index 0000000000..2347c40c48 --- /dev/null +++ b/router-tests/testutil/oauth_server_test.go @@ -0,0 +1,234 @@ +package testutil + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOAuthTestServer_ASMetadata(t *testing.T) { + srv, err := NewOAuthTestServer(t, nil) + require.NoError(t, err) + defer srv.Close() + + resp, err := http.Get(srv.Issuer() + "/.well-known/oauth-authorization-server") + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var meta map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&meta)) + + assert.Equal(t, srv.Issuer(), meta["issuer"]) + assert.Equal(t, srv.Issuer()+"/token", meta["token_endpoint"]) + assert.Equal(t, srv.Issuer()+"/register", meta["registration_endpoint"]) + assert.Equal(t, srv.Issuer()+"/authorize", meta["authorization_endpoint"]) + assert.Equal(t, srv.JWKSURL(), meta["jwks_uri"]) +} + +func TestOAuthTestServer_ClientCredentials(t *testing.T) { + srv, err := NewOAuthTestServer(t, &OAuthTestServerOptions{ + PreRegisteredClients: []*OAuthClient{ + { + ClientID: "test-client", + ClientSecret: "test-secret", + GrantTypes: []string{"client_credentials"}, + Scope: "mcp:tools:read mcp:tools:write", + }, + }, + }) + require.NoError(t, err) + defer srv.Close() + + t.Run("valid credentials with Basic auth", func(t *testing.T) { + form := url.Values{"grant_type": {"client_credentials"}} + req, err := http.NewRequest(http.MethodPost, srv.TokenEndpoint(), strings.NewReader(form.Encode())) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth("test-client", "test-secret") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var tokenResp map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&tokenResp)) + + assert.Equal(t, "Bearer", tokenResp["token_type"]) + assert.NotEmpty(t, tokenResp["access_token"]) + assert.Equal(t, "mcp:tools:read mcp:tools:write", tokenResp["scope"]) + }) + + t.Run("valid credentials with POST body", func(t *testing.T) { + form := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {"test-client"}, + "client_secret": {"test-secret"}, + } + resp, err := http.Post(srv.TokenEndpoint(), "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("scope override", func(t *testing.T) { + form := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {"test-client"}, + "client_secret": {"test-secret"}, + "scope": {"mcp:admin"}, + } + resp, err := http.Post(srv.TokenEndpoint(), "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) + require.NoError(t, err) + defer resp.Body.Close() + + var tokenResp map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&tokenResp)) + assert.Equal(t, "mcp:admin", tokenResp["scope"]) + }) + + t.Run("bad secret rejected", func(t *testing.T) { + form := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {"test-client"}, + "client_secret": {"wrong"}, + } + resp, err := http.Post(srv.TokenEndpoint(), "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("unknown client rejected", func(t *testing.T) { + form := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {"ghost"}, + "client_secret": {"nope"}, + } + resp, err := http.Post(srv.TokenEndpoint(), "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) +} + +func TestOAuthTestServer_DynamicRegistration(t *testing.T) { + srv, err := NewOAuthTestServer(t, nil) + require.NoError(t, err) + defer srv.Close() + + // Register a client dynamically + body := `{"client_name":"my-test","grant_types":["client_credentials"],"token_endpoint_auth_method":"client_secret_basic"}` + resp, err := http.Post(srv.Issuer()+"/register", "application/json", strings.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + var regResp map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(®Resp)) + + clientID, _ := regResp["client_id"].(string) + clientSecret, _ := regResp["client_secret"].(string) + require.NotEmpty(t, clientID) + require.NotEmpty(t, clientSecret) + + // Use the dynamically registered client to get a token + form := url.Values{"grant_type": {"client_credentials"}} + req, err := http.NewRequest(http.MethodPost, srv.TokenEndpoint(), strings.NewReader(form.Encode())) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(clientID, clientSecret) + + tokenResp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer tokenResp.Body.Close() + + assert.Equal(t, http.StatusOK, tokenResp.StatusCode) +} + +func TestOAuthTestServer_AuthorizationCodeFlow(t *testing.T) { + srv, err := NewOAuthTestServer(t, &OAuthTestServerOptions{ + PreRegisteredClients: []*OAuthClient{ + { + ClientID: "authcode-client", + ClientSecret: "authcode-secret", + GrantTypes: []string{"authorization_code"}, + Scope: "openid", + }, + }, + }) + require.NoError(t, err) + defer srv.Close() + + // Step 1: Hit /authorize — should redirect with a code + authURL := fmt.Sprintf("%s/authorize?client_id=authcode-client&redirect_uri=http://localhost:9999/callback&scope=openid&state=xyz123", srv.Issuer()) + + client := &http.Client{CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse // don't follow redirects + }} + + resp, err := client.Get(authURL) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusFound, resp.StatusCode) + + loc, err := resp.Location() + require.NoError(t, err) + + code := loc.Query().Get("code") + state := loc.Query().Get("state") + require.NotEmpty(t, code) + assert.Equal(t, "xyz123", state) + + // Step 2: Exchange code for token + form := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "client_id": {"authcode-client"}, + "client_secret": {"authcode-secret"}, + } + tokenResp, err := http.Post(srv.TokenEndpoint(), "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) + require.NoError(t, err) + defer tokenResp.Body.Close() + + assert.Equal(t, http.StatusOK, tokenResp.StatusCode) + + var tokens map[string]any + require.NoError(t, json.NewDecoder(tokenResp.Body).Decode(&tokens)) + assert.NotEmpty(t, tokens["access_token"]) + + // Step 3: Code cannot be reused + tokenResp2, err := http.Post(srv.TokenEndpoint(), "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) + require.NoError(t, err) + defer tokenResp2.Body.Close() + + assert.Equal(t, http.StatusBadRequest, tokenResp2.StatusCode) +} + +func TestOAuthTestServer_CreateTokenDirectly(t *testing.T) { + srv, err := NewOAuthTestServer(t, nil) + require.NoError(t, err) + defer srv.Close() + + token, err := srv.CreateTokenWithScopes("test-user", []string{"mcp:connect", "mcp:tools:read"}) + require.NoError(t, err) + require.NotEmpty(t, token) + + // Token should be a valid JWT (3 dot-separated parts) + parts := strings.Split(token, ".") + assert.Len(t, parts, 3) +} \ No newline at end of file diff --git a/router/core/graph_server.go b/router/core/graph_server.go index f478e53590..23812b8fe4 100644 --- a/router/core/graph_server.go +++ b/router/core/graph_server.go @@ -1393,7 +1393,7 @@ func (s *graphServer) buildGraphMux( // We support the MCP only on the base graph. Feature flags are not supported yet. if opts.IsBaseGraph() && s.mcpServer != nil { - if mErr := s.mcpServer.Reload(executor.ClientSchema); mErr != nil { + if mErr := s.mcpServer.Reload(executor.ClientSchema, opts.EngineConfig.FieldConfigurations); mErr != nil { return nil, fmt.Errorf("failed to reload MCP server: %w", mErr) } } diff --git a/router/core/router.go b/router/core/router.go index f25778ec7a..f4e63a10b0 100644 --- a/router/core/router.go +++ b/router/core/router.go @@ -978,6 +978,16 @@ func (r *Router) bootstrap(ctx context.Context) error { mcpOpts = append(mcpOpts, mcpserver.WithCORS(*r.corsOptions)) } + // Add OAuth configuration if enabled + if r.mcp.OAuth.Enabled { + mcpOpts = append(mcpOpts, mcpserver.WithOAuth(&r.mcp.OAuth)) + + // Add server base URL for OAuth discovery if configured + if r.mcp.Server.BaseURL != "" { + mcpOpts = append(mcpOpts, mcpserver.WithServerBaseURL(r.mcp.Server.BaseURL)) + } + } + mcpGraphQLEndpoint := r.graphqlEndpointURL if r.mcp.RouterURL != "" { mcpGraphQLEndpoint = r.mcp.RouterURL diff --git a/router/go.mod b/router/go.mod index a2b565e403..67c3df1996 100644 --- a/router/go.mod +++ b/router/go.mod @@ -13,7 +13,7 @@ require ( github.com/go-redis/redis_rate/v10 v10.0.1 github.com/gobwas/ws v1.4.0 github.com/goccy/go-yaml v1.17.1 - github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/gorilla/websocket v1.5.1 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-retryablehttp v0.7.7 @@ -36,24 +36,24 @@ require ( go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 go.opentelemetry.io/contrib/propagators/b3 v1.23.0 go.opentelemetry.io/contrib/propagators/jaeger v1.23.0 - go.opentelemetry.io/otel v1.39.0 + go.opentelemetry.io/otel v1.36.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.44.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.23.1 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 go.opentelemetry.io/otel/exporters/prometheus v0.50.0 - go.opentelemetry.io/otel/metric v1.39.0 - go.opentelemetry.io/otel/sdk v1.39.0 - go.opentelemetry.io/otel/sdk/metric v1.39.0 - go.opentelemetry.io/otel/trace v1.39.0 + go.opentelemetry.io/otel/metric v1.36.0 + go.opentelemetry.io/otel/sdk v1.36.0 + go.opentelemetry.io/otel/sdk/metric v1.36.0 + go.opentelemetry.io/otel/trace v1.36.0 go.uber.org/atomic v1.11.0 go.uber.org/automaxprocs v1.5.3 go.uber.org/zap v1.27.0 go.withmatt.com/connect-brotli v0.4.0 - golang.org/x/sync v0.19.0 - golang.org/x/sys v0.39.0 // indirect - google.golang.org/grpc v1.79.3 - google.golang.org/protobuf v1.36.10 + golang.org/x/sync v0.17.0 + golang.org/x/sys v0.40.0 // indirect + google.golang.org/grpc v1.71.0 + google.golang.org/protobuf v1.36.9 ) require ( @@ -75,7 +75,6 @@ require ( github.com/hashicorp/go-plugin v1.6.3 github.com/iancoleman/strcase v0.3.0 github.com/klauspost/compress v1.18.0 - github.com/mark3labs/mcp-go v0.36.0 github.com/minio/minio-go/v7 v7.0.74 github.com/posthog/posthog-go v1.5.5 github.com/pquerna/cachecontrol v0.2.0 @@ -86,14 +85,21 @@ require ( go.uber.org/goleak v1.3.0 go.uber.org/ratelimit v0.3.1 golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 - golang.org/x/net v0.48.0 - golang.org/x/text v0.32.0 + golang.org/x/net v0.46.0 + golang.org/x/text v0.30.0 golang.org/x/time v0.9.0 ) +require ( + github.com/frankban/quicktest v1.14.6 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.3 // indirect + golang.org/x/oauth2 v0.34.0 // indirect +) + require ( github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // indirect - github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/benbjohnson/clock v1.3.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect @@ -123,17 +129,16 @@ require ( github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/yamux v0.1.1 // indirect - github.com/invopop/jsonschema v0.13.0 // indirect github.com/jensneuse/byte-template v0.0.0-20231025215717-69252eb3ed56 // indirect github.com/jhump/protoreflect v1.17.0 // indirect github.com/kingledion/go-tools v0.6.0 // indirect github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/modelcontextprotocol/go-sdk v1.4.0 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nats-io/nkeys v0.4.7 // indirect github.com/oklog/run v1.0.0 // indirect @@ -153,7 +158,6 @@ require ( github.com/sergi/go-diff v1.3.1 // indirect github.com/shoenig/go-m1cpu v0.1.7 // indirect github.com/sirupsen/logrus v1.9.3 // indirect - github.com/spf13/cast v1.7.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect @@ -161,16 +165,15 @@ require ( github.com/tklauser/numcpus v0.6.1 // indirect github.com/twmb/franz-go/pkg/kmsg v1.7.0 // indirect github.com/vbatts/tar-split v0.12.1 // indirect - github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect go.opentelemetry.io/proto/otlp v1.4.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.46.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + golang.org/x/crypto v0.43.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a // indirect gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/router/go.sum b/router/go.sum index 3af92643bc..c8d000743e 100644 --- a/router/go.sum +++ b/router/go.sum @@ -19,8 +19,6 @@ github.com/alicebob/miniredis/v2 v2.34.0 h1:mBFWMaJSNL9RwdGRyEDoAAv8OQc5UlEhLDQg github.com/alicebob/miniredis/v2 v2.34.0/go.mod h1:kWShP4b58T1CW0Y5dViCd5ztzrDqRWqM3nksiyXk5s8= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= -github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= -github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -53,6 +51,7 @@ github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRcc github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -111,8 +110,8 @@ github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -124,6 +123,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.3 h1:oNx7IdTI936V8CQRveCjaxOiegWwvM7kqkbXTpyiovI= github.com/google/go-containerregistry v0.20.3/go.mod h1:w00pIgBRDVUDFM6bq+Qx8lwNWK+cxgCuX1vd3PIBDNI= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -154,8 +155,6 @@ github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= -github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= -github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jensneuse/abstractlogger v0.0.4 h1:sa4EH8fhWk3zlTDbSncaWKfwxYM8tYSlQ054ETLyyQY= github.com/jensneuse/abstractlogger v0.0.4/go.mod h1:6WuamOHuykJk8zED/R0LNiLhWR6C7FIAo43ocUEB3mo= github.com/jensneuse/byte-template v0.0.0-20231025215717-69252eb3ed56 h1:wo26fh6a6Za0cOMZIopD2sfH/kq83SJ89ixUWl7pCWc= @@ -166,7 +165,6 @@ github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5Xum github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kingledion/go-tools v0.6.0 h1:y8C/4mWoHgLkO45dB+Y/j0o4Y4WUB5lDTAcMPMtFpTg= github.com/kingledion/go-tools v0.6.0/go.mod h1:qcDJQxBui/H/hterGb90GMlLs9Yi7QrwaJL8OGdbsms= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= @@ -187,10 +185,6 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mark3labs/mcp-go v0.36.0 h1:rIZaijrRYPeSbJG8/qNDe0hWlGrCJ7FWHNMz2SQpTis= -github.com/mark3labs/mcp-go v0.36.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -207,6 +201,8 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8= +github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nats-io/nats.go v1.35.0 h1:XFNqNM7v5B+MQMKqVGAyHwYhyKb48jrenXNxIU20ULk= @@ -229,6 +225,7 @@ github.com/phf/go-queue v0.0.0-20170504031614-9abe38d0371d h1:U+PMnTlV2tu7RuMK5e github.com/phf/go-queue v0.0.0-20170504031614-9abe38d0371d/go.mod h1:lXfE4PvvTW5xOjO6Mba8zDPyw8M93B6AQ7frTGnMlA8= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -255,6 +252,7 @@ github.com/r3labs/sse/v2 v2.8.1/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktEm github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= @@ -263,6 +261,10 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+x github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sebdah/goldie/v2 v2.7.1 h1:PkBHymaYdtvEkZV7TmyqKxdmn5/Vcj+8TpATWZjnG5E= github.com/sebdah/goldie/v2 v2.7.1/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= @@ -279,8 +281,6 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= -github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -323,8 +323,6 @@ github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnn github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/vektah/gqlparser/v2 v2.5.30 h1:EqLwGAFLIzt1wpx1IPpY67DwUujF1OfzgEyDsLrN6kE= github.com/vektah/gqlparser/v2 v2.5.30/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= -github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= -github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/wundergraph/astjson v1.1.0 h1:xORDosrZ87zQFJwNGe/HIHXqzpdHOFmqWgykCLVL040= github.com/wundergraph/astjson v1.1.0/go.mod h1:h12D/dxxnedtLzsKyBLK7/Oe4TAoGpRVC9nDpDrZSWw= github.com/wundergraph/go-arena v1.1.0 h1:9+wSRkJAkA2vbYHp6s8tEGhPViRGQNGXqPHT0QzhdIc= @@ -387,8 +385,8 @@ go.withmatt.com/connect-brotli v0.4.0 h1:7ObWkYmEbUXK3EKglD0Lgj0BBnnD3jNdAxeDRct go.withmatt.com/connect-brotli v0.4.0/go.mod h1:c2eELz56za+/Mxh1yJrlglZ4VM9krpOCPqS2Vxf8NVk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -397,11 +395,13 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -418,29 +418,31 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gonum.org/v1/gonum v0.14.0 h1:2NiG67LD1tEH0D7kM+ps2V+fXmsAnpUeec7n8tcr4S0= +gonum.org/v1/gonum v0.14.0/go.mod h1:AoWeoz0becf9QMWtE8iWXNXc27fK4fNeHNf/oMejGfU= +google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a h1:DMCgtIAIQGZqJXMVzJF4MV8BlWoJh2ZuFiRdAleyr58= +google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a/go.mod h1:y2yVLIE/CSMCPXaHnSKXxu1spLPnglFLegmgdY23uuE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a h1:tPE/Kp+x9dMSwUm/uM0JKK0IfdiJkwAbSMSeZBXXJXc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y= gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index d4f83e381f..de2b49d5a8 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -1112,18 +1112,54 @@ type CacheWarmupConfiguration struct { } type MCPConfiguration struct { - Enabled bool `yaml:"enabled" envDefault:"false" env:"MCP_ENABLED"` - Server MCPServer `yaml:"server,omitempty"` - Storage MCPStorageConfig `yaml:"storage,omitempty"` - Session MCPSessionConfig `yaml:"session,omitempty"` - GraphName string `yaml:"graph_name" envDefault:"mygraph" env:"MCP_GRAPH_NAME"` - ExcludeMutations bool `yaml:"exclude_mutations" envDefault:"false" env:"MCP_EXCLUDE_MUTATIONS"` - EnableArbitraryOperations bool `yaml:"enable_arbitrary_operations" envDefault:"false" env:"MCP_ENABLE_ARBITRARY_OPERATIONS"` - ExposeSchema bool `yaml:"expose_schema" envDefault:"false" env:"MCP_EXPOSE_SCHEMA"` - RouterURL string `yaml:"router_url,omitempty" env:"MCP_ROUTER_URL"` + Enabled bool `yaml:"enabled" envDefault:"false" env:"MCP_ENABLED"` + Server MCPServer `yaml:"server,omitempty"` + Storage MCPStorageConfig `yaml:"storage,omitempty"` + Session MCPSessionConfig `yaml:"session,omitempty"` + GraphName string `yaml:"graph_name" envDefault:"mygraph" env:"MCP_GRAPH_NAME"` + ExcludeMutations bool `yaml:"exclude_mutations" envDefault:"false" env:"MCP_EXCLUDE_MUTATIONS"` + EnableArbitraryOperations bool `yaml:"enable_arbitrary_operations" envDefault:"false" env:"MCP_ENABLE_ARBITRARY_OPERATIONS"` + ExposeSchema bool `yaml:"expose_schema" envDefault:"false" env:"MCP_EXPOSE_SCHEMA"` + RouterURL string `yaml:"router_url,omitempty" env:"MCP_ROUTER_URL"` // OmitToolNamePrefix removes the "execute_operation_" prefix from MCP tool names. // When enabled, GetUser becomes get_user. When disabled (default), GetUser becomes execute_operation_get_user. - OmitToolNamePrefix bool `yaml:"omit_tool_name_prefix" envDefault:"false" env:"MCP_OMIT_TOOL_NAME_PREFIX"` + OmitToolNamePrefix bool `yaml:"omit_tool_name_prefix" envDefault:"false" env:"MCP_OMIT_TOOL_NAME_PREFIX"` + OAuth MCPOAuthConfiguration `yaml:"oauth,omitempty" envPrefix:"MCP_OAUTH_"` +} + +type MCPOAuthConfiguration struct { + Enabled bool `yaml:"enabled" envDefault:"false" env:"ENABLED"` + JWKS []JWKSConfiguration `yaml:"jwks"` + AuthorizationServerURL string `yaml:"authorization_server_url,omitempty" env:"AUTHORIZATION_SERVER_URL"` + // Scopes configures which OAuth scopes are required for different MCP operations. + Scopes MCPOAuthScopesConfiguration `yaml:"scopes,omitempty"` + // ScopeChallengeIncludeTokenScopes controls whether the server includes the token's existing scopes + // in the scope parameter of 403 insufficient_scope responses. + // When false (default), only the scopes required for the operation are returned (RFC 6750 strict). + // When true, the token's existing scopes are unioned with the required scopes. + // This is a workaround for MCP client SDKs that replace rather than accumulate scopes. + ScopeChallengeIncludeTokenScopes bool `yaml:"scope_challenge_include_token_scopes" envDefault:"false" env:"SCOPE_CHALLENGE_INCLUDE_TOKEN_SCOPES"` +} + +// MCPOAuthScopesConfiguration defines which scopes are required for different MCP operations. +// All configured scopes are automatically unioned into scopes_supported for OAuth metadata discovery. +type MCPOAuthScopesConfiguration struct { + // Initialize specifies scopes required for ALL HTTP requests (checked before JSON-RPC parsing). + // This is the baseline scope needed to establish an MCP connection. + Initialize []string `yaml:"initialize,omitempty"` + // ToolsList specifies scopes required for the tools/list MCP method. + ToolsList []string `yaml:"tools_list,omitempty"` + // ToolsCall specifies scopes required for the tools/call MCP method (any tool). + ToolsCall []string `yaml:"tools_call,omitempty"` + // ExecuteGraphQL specifies scopes required to call the execute_graphql built-in tool. + // Additive to tools_call scopes. Only relevant when enable_arbitrary_operations is true. + ExecuteGraphQL []string `yaml:"execute_graphql,omitempty"` + // GetOperationInfo specifies scopes required to call the get_operation_info built-in tool. + // Additive to tools_call scopes. + GetOperationInfo []string `yaml:"get_operation_info,omitempty"` + // GetSchema specifies scopes required to call the get_schema built-in tool. + // Additive to tools_call scopes. Only relevant when expose_schema is true. + GetSchema []string `yaml:"get_schema,omitempty"` } type MCPSessionConfig struct { diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index 32429f30ea..b04134544f 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -2349,10 +2349,8 @@ "format": "hostname-port" }, "base_url": { - "deprecated": true, - "deprecationMessage": "The base_url is deprecated. This property was related to the SSE protocol that is not supported anymore.", "type": "string", - "description": "The base URL of the MCP server. This is the URL advertised to the LLM clients when SSE is used as primary transport. By default, the base URL is relative to the URL that the router is running on. The URL is specified as a string with the format 'scheme://host:port'.", + "description": "The base URL of the MCP server used for OAuth 2.0 discovery (RFC 9728). This URL is advertised in the Protected Resource Metadata endpoint and used to construct the resource metadata URL. Required when OAuth is enabled. The URL is specified as a string with the format 'scheme://host:port'.", "format": "http-url" } } @@ -2410,7 +2408,234 @@ "type": "boolean", "default": false, "description": "When enabled, MCP tool names generated from GraphQL operations omit the 'execute_operation_' prefix. For example, the GraphQL operation 'GetUser' results in a tool named 'get_user' instead of 'execute_operation_get_user'." + }, + "oauth": { + "type": "object", + "description": "OAuth/JWKS authentication configuration for the MCP server. When enabled, MCP tool calls require valid JWT authentication and the server implements OAuth 2.0 discovery mechanisms (RFC 8414, RFC 9728).", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable OAuth/JWKS authentication for the MCP server. When true, all MCP tool calls must include a valid JWT token." + }, + "authorization_server_url": { + "type": "string", + "description": "The base URL of the OAuth 2.0 authorization server. This URL is advertised to MCP clients via the Protected Resource Metadata endpoint (RFC 9728) to enable automatic discovery of OAuth endpoints. Clients will append '/.well-known/oauth-authorization-server' to this URL to discover token, authorization, and registration endpoints. Example: 'https://auth.example.com'", + "format": "http-url" + }, + "scopes": { + "type": "object", + "description": "Configures which OAuth scopes are required for different MCP operations. All configured scopes are automatically unioned into 'scopes_supported' for OAuth metadata discovery.", + "additionalProperties": false, + "properties": { + "initialize": { + "type": "array", + "description": "Scopes required for ALL HTTP requests (checked before JSON-RPC parsing). This is the baseline scope needed to establish an MCP connection.", + "items": { "type": "string" } + }, + "tools_list": { + "type": "array", + "description": "Scopes required for the tools/list MCP method.", + "items": { "type": "string" } + }, + "tools_call": { + "type": "array", + "description": "Scopes required for the tools/call MCP method (any tool).", + "items": { "type": "string" } + }, + "execute_graphql": { + "type": "array", + "description": "Scopes required to call the execute_graphql built-in tool. Additive to tools_call scopes. Only relevant when enable_arbitrary_operations is true.", + "items": { "type": "string" } + }, + "get_operation_info": { + "type": "array", + "description": "Scopes required to call the get_operation_info built-in tool. Additive to tools_call scopes.", + "items": { "type": "string" } + }, + "get_schema": { + "type": "array", + "description": "Scopes required to call the get_schema built-in tool. Additive to tools_call scopes. Only relevant when expose_schema is true.", + "items": { "type": "string" } + } + } + }, + "scope_challenge_include_token_scopes": { + "type": "boolean", + "default": false, + "description": "When true, includes the token's existing scopes in the scope parameter of 403 insufficient_scope responses (workaround for MCP client SDKs that replace rather than accumulate scopes). When false (default), only the scopes required for the operation are returned (RFC 6750 strict)." + }, + "jwks": { + "type": "array", + "description": "List of JWKS (JSON Web Key Set) configurations for JWT token verification. Multiple JWKS providers can be configured for different authentication sources.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { + "type": "string", + "description": "The URL of the JWKs. The JWKs are used to verify the JWT (JSON Web Token). The URL is specified as a string with the format 'scheme://host:port'.", + "format": "http-url" + }, + "audiences": { + "type": "array", + "description": "The audiences of the JWKs. The audiences are used to verify the JWT (JSON Web Token). The audiences are specified as a list of strings.", + "items": { + "type": "string" + } + }, + "secret": { + "type": "string", + "description": "The secret of the JWKs" + }, + "symmetric_algorithm": { + "type": "string", + "description": "The symmetric algorithm used", + "enum": ["HS256", "HS384", "HS512"] + }, + "header_key_id": { + "type": "string", + "description": "The KID header of the JWK token created using the secret" + }, + "allowed_use": { + "type": "array", + "description": "The allowed value of the use parameter for the JWKs. If not specified, only keys with use set to 'sig' will be used. If your server provides no use, you can add an empty value to allow those keys.", + "default": ["sig"], + "items": { + "type": "string", + "enum": [ + "sig", + "enc", + "" + ] + } + }, + "algorithms": { + "type": "array", + "description": "The allowed algorithms for the keys that are retrieved from the JWKs. An empty list means that all algorithms are allowed.", + "items": { + "type": "string", + "enum": [ + "HS256", + "HS384", + "HS512", + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512", + "PS256", + "PS384", + "PS512", + "EdDSA" + ] + } + }, + "refresh_interval": { + "type": "string", + "duration": { + "minimum": "5s" + }, + "description": "The interval at which the JWKs are refreshed. The period is specified as a string with a number and a unit, e.g. 10ms, 1s, 1m, 1h. The supported units are 'ms', 's', 'm', 'h'.", + "default": "1m" + }, + "refresh_unknown_kid": { + "type": "object", + "description": "Controls rate-limited refresh behavior when a JWT KID is unknown.", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable refresh attempts on unknown KID.", + "default": false + }, + "max_wait": { + "type": "string", + "description": "Maximum time to wait for a refresh permit before giving up.", + "default": "10s", + "duration": { + "minimum": "0s" + } + }, + "interval": { + "type": "string", + "description": "Token refill interval for the rate limiter.", + "default": "1m", + "duration": { + "minimum": "1s" + } + }, + "burst": { + "type": "integer", + "description": "Burst size for the rate limiter.", + "default": 2, + "minimum": 1 + } + } + } + }, + "oneOf": [ + { + "required": ["url"], + "not": { + "anyOf": [ + { + "required": ["secret"] + }, + { + "required": ["symmetric_algorithm"] + }, + { + "required": ["header_key_id"] + } + ] + } + }, + { + "required": ["secret", "symmetric_algorithm", "header_key_id"], + "not": { + "anyOf": [ + { + "required": ["url"] + }, + { + "required": ["algorithms"] + }, + { + "required": ["refresh_interval"] + }, + { + "required": ["refresh_unknown_kid"] + } + ] + } + } + ] + } + } + } } + }, + "if": { + "properties": { + "oauth": { + "properties": { + "enabled": { "const": true } + }, + "required": ["enabled"] + } + }, + "required": ["oauth"] + }, + "then": { + "properties": { + "server": { + "required": ["base_url"] + } + }, + "required": ["server"] } }, "connect_rpc": { @@ -3709,6 +3934,138 @@ } }, "$defs": { + "jwks_configuration": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { + "type": "string", + "description": "The URL of the JWKs. The JWKs are used to verify the JWT (JSON Web Token). The URL is specified as a string with the format 'scheme://host:port'.", + "format": "http-url" + }, + "audiences": { + "type": "array", + "description": "The audiences of the JWKs. The audiences are used to verify the JWT (JSON Web Token). The audiences are specified as a list of strings.", + "items": { + "type": "string" + } + }, + "secret": { + "type": "string", + "description": "The secret of the JWKs" + }, + "symmetric_algorithm": { + "type": "string", + "description": "The symmetric algorithm used", + "enum": ["HS256", "HS384", "HS512"] + }, + "header_key_id": { + "type": "string", + "description": "The KID header of the JWK token created using the secret" + }, + "algorithms": { + "type": "array", + "description": "The allowed algorithms for the keys that are retrieved from the JWKs. An empty list means that all algorithms are allowed.", + "items": { + "type": "string", + "enum": [ + "HS256", + "HS384", + "HS512", + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512", + "PS256", + "PS384", + "PS512", + "EdDSA" + ] + } + }, + "refresh_interval": { + "type": "string", + "duration": { + "minimum": "5s" + }, + "description": "The interval at which the JWKs are refreshed. The period is specified as a string with a number and a unit, e.g. 10ms, 1s, 1m, 1h. The supported units are 'ms', 's', 'm', 'h'.", + "default": "1m" + }, + "refresh_unknown_kid": { + "type": "object", + "description": "Controls rate-limited refresh behavior when a JWT KID is unknown.", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable refresh attempts on unknown KID.", + "default": false + }, + "max_wait": { + "type": "string", + "description": "Maximum time to wait for a refresh permit before giving up.", + "default": "10s", + "duration": { + "minimum": "0s" + } + }, + "interval": { + "type": "string", + "description": "Token refill interval for the rate limiter.", + "default": "1m", + "duration": { + "minimum": "1s" + } + }, + "burst": { + "type": "integer", + "description": "Burst size for the rate limiter.", + "default": 2, + "minimum": 1 + } + } + } + }, + "oneOf": [ + { + "required": ["url"], + "not": { + "anyOf": [ + { + "required": ["secret"] + }, + { + "required": ["symmetric_algorithm"] + }, + { + "required": ["header_key_id"] + } + ] + } + }, + { + "required": ["secret", "symmetric_algorithm", "header_key_id"], + "not": { + "anyOf": [ + { + "required": ["url"] + }, + { + "required": ["algorithms"] + }, + { + "required": ["refresh_interval"] + }, + { + "required": ["refresh_unknown_kid"] + } + ] + } + } + ] + }, "traffic_shaping_subgraph_request_rule": { "type": "object", "additionalProperties": false, diff --git a/router/pkg/config/testdata/config_defaults.json b/router/pkg/config/testdata/config_defaults.json index 6afe8c00b1..572ca4411d 100644 --- a/router/pkg/config/testdata/config_defaults.json +++ b/router/pkg/config/testdata/config_defaults.json @@ -164,7 +164,21 @@ "EnableArbitraryOperations": false, "ExposeSchema": false, "RouterURL": "", - "OmitToolNamePrefix": false + "OmitToolNamePrefix": false, + "OAuth": { + "Enabled": false, + "JWKS": null, + "AuthorizationServerURL": "", + "Scopes": { + "Initialize": null, + "ToolsList": null, + "ToolsCall": null, + "ExecuteGraphQL": null, + "GetOperationInfo": null, + "GetSchema": null + }, + "ScopeChallengeIncludeTokenScopes": false + } }, "ConnectRPC": { "Enabled": false, diff --git a/router/pkg/config/testdata/config_full.json b/router/pkg/config/testdata/config_full.json index e09957bc29..96dde9ad7a 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -209,7 +209,21 @@ "EnableArbitraryOperations": false, "ExposeSchema": false, "RouterURL": "https://cosmo-router.wundergraph.com", - "OmitToolNamePrefix": false + "OmitToolNamePrefix": false, + "OAuth": { + "Enabled": false, + "JWKS": null, + "AuthorizationServerURL": "", + "Scopes": { + "Initialize": null, + "ToolsList": null, + "ToolsCall": null, + "ExecuteGraphQL": null, + "GetOperationInfo": null, + "GetSchema": null + }, + "ScopeChallengeIncludeTokenScopes": false + } }, "ConnectRPC": { "Enabled": false, diff --git a/router/pkg/mcpserver/auth_middleware.go b/router/pkg/mcpserver/auth_middleware.go new file mode 100644 index 0000000000..4c0d4a65a8 --- /dev/null +++ b/router/pkg/mcpserver/auth_middleware.go @@ -0,0 +1,465 @@ +package mcpserver + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "slices" + "strings" + "sync" + + "github.com/wundergraph/cosmo/router/pkg/authentication" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" +) + +type contextKey string + +const ( + userClaimsContextKey contextKey = "mcp_user_claims" + // maxBodyBytes is the maximum size of the request body we'll read for scope checking. + // This prevents memory exhaustion from oversized payloads. + maxBodyBytes int64 = 1 << 20 // 1 MB +) + +// mcpAuthProvider adapts MCP headers to the authentication.Provider interface +type mcpAuthProvider struct { + headers http.Header +} + +func (p *mcpAuthProvider) AuthenticationHeaders() http.Header { + return p.headers +} + +// MCPScopeConfig holds the structured scope requirements for MCP operations. +type MCPScopeConfig struct { + Initialize []string // Scopes required for all HTTP requests + ToolsList []string // Scopes required for tools/list + ToolsCall []string // Scopes required for tools/call (any tool) + ExecuteGraphQL []string // Scopes required for the execute_graphql built-in tool + GetOperationInfo []string // Scopes required for the get_operation_info built-in tool + GetSchema []string // Scopes required for the get_schema built-in tool +} + +// MCPAuthMiddleware creates authentication middleware for MCP tools and resources +type MCPAuthMiddleware struct { + authenticator authentication.Authenticator + enabled bool + resourceMetadataURL string + scopes MCPScopeConfig + scopeChallengeIncludeTokenScopes bool + toolScopesMu sync.RWMutex + toolScopes map[string][][]string // toolName → OR-of-AND scope groups + scopeExtractorMu sync.RWMutex + scopeExtractor *ScopeExtractor // for runtime scope checking of execute_graphql +} + +// NewMCPAuthMiddleware creates a new authentication middleware using the existing +// authentication infrastructure from the router +func NewMCPAuthMiddleware(tokenDecoder authentication.TokenDecoder, enabled bool, resourceMetadataURL string, scopes MCPScopeConfig, scopeChallengeIncludeTokenScopes bool) (*MCPAuthMiddleware, error) { + if tokenDecoder == nil { + return nil, fmt.Errorf("token decoder must be provided") + } + + // Use the existing HttpHeaderAuthenticator with default settings (Authorization header, Bearer prefix) + // This ensures consistency with the rest of the router's authentication logic + authenticator, err := authentication.NewHttpHeaderAuthenticator(authentication.HttpHeaderAuthenticatorOptions{ + Name: "mcp-auth", + TokenDecoder: tokenDecoder, + // HeaderSourcePrefixes defaults to {"Authorization": {"Bearer"}} when not specified + // This can be extended in the future to support additional schemes like DPoP + }) + if err != nil { + return nil, fmt.Errorf("failed to create authenticator: %w", err) + } + + return &MCPAuthMiddleware{ + authenticator: authenticator, + enabled: enabled, + resourceMetadataURL: resourceMetadataURL, + scopes: scopes, + scopeChallengeIncludeTokenScopes: scopeChallengeIncludeTokenScopes, + }, nil +} + +// SetToolScopes atomically replaces the per-tool scope map. +// Called during Reload() after tools are registered with their extracted scopes. +func (m *MCPAuthMiddleware) SetToolScopes(scopes map[string][][]string) { + m.toolScopesMu.Lock() + defer m.toolScopesMu.Unlock() + m.toolScopes = scopes +} + +// getToolScopes returns the OR-of-AND scope groups for the given tool name. +// Returns nil if the tool has no per-tool scope requirements. +func (m *MCPAuthMiddleware) getToolScopes(toolName string) [][]string { + m.toolScopesMu.RLock() + defer m.toolScopesMu.RUnlock() + if m.toolScopes == nil { + return nil + } + return m.toolScopes[toolName] +} + +// getBuiltinToolScopes returns the configured scopes for a built-in tool. +// Returns nil if the tool is not a built-in or has no configured scopes. +func (m *MCPAuthMiddleware) getBuiltinToolScopes(toolName string) []string { + switch toolName { + case "execute_graphql": + return m.scopes.ExecuteGraphQL + case "get_operation_info": + return m.scopes.GetOperationInfo + case "get_schema": + return m.scopes.GetSchema + default: + return nil + } +} + +// SetScopeExtractor atomically replaces the scope extractor used for +// runtime scope checking of execute_graphql arbitrary operations. +// Called during Reload() after the schema is loaded. +func (m *MCPAuthMiddleware) SetScopeExtractor(extractor *ScopeExtractor) { + m.scopeExtractorMu.Lock() + defer m.scopeExtractorMu.Unlock() + m.scopeExtractor = extractor +} + +// getScopeExtractor returns the current scope extractor (thread-safe). +func (m *MCPAuthMiddleware) getScopeExtractor() *ScopeExtractor { + m.scopeExtractorMu.RLock() + defer m.scopeExtractorMu.RUnlock() + return m.scopeExtractor +} + +// authenticateRequest extracts and validates the JWT token using the existing +// authentication infrastructure from the router +func (m *MCPAuthMiddleware) authenticateRequest(ctx context.Context) (authentication.Claims, error) { + // Extract headers from context (passed by mcp-go HTTP transport) + headers, err := headersFromContext(ctx) + if err != nil { + return nil, fmt.Errorf("missing request headers: %w", err) + } + + // Use the existing authenticator instead of manual token parsing + // This provides better error messages and supports multiple authentication schemes + provider := &mcpAuthProvider{headers: headers} + claims, err := m.authenticator.Authenticate(ctx, provider) + if err != nil { + return nil, fmt.Errorf("authentication failed: %w", err) + } + + // If claims are empty, treat as authentication failure + if len(claims) == 0 { + return nil, fmt.Errorf("authentication failed: no valid credentials provided") + } + + // Note: Scope validation is now handled at HTTP level, not here + // This is per MCP spec: authorization must be at HTTP level + + return claims, nil +} + +// HTTPMiddleware wraps HTTP handlers with authentication for ALL MCP operations +// Per MCP specification: "authorization MUST be included in every HTTP request from client to server" +func (m *MCPAuthMiddleware) HTTPMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !m.enabled { + next.ServeHTTP(w, r) + return + } + + // Create a provider from the HTTP request headers + provider := &mcpAuthProvider{headers: r.Header} + + // Validate the token + claims, err := m.authenticator.Authenticate(r.Context(), provider) + if err != nil || len(claims) == 0 { + m.sendUnauthorizedResponse(w, err) + return + } + + // Step 1: Validate HTTP-level required scopes (initialize) + if len(m.scopes.Initialize) > 0 { + if err := m.validateScopesForRequest(claims, m.scopes.Initialize); err != nil { + m.sendInsufficientScopeResponse(w, m.scopes.Initialize, claims, err) + return + } + } + + // Step 2: Parse JSON-RPC request to check method-level scopes + // Read body to extract method name (only if body exists) + // Use LimitReader to prevent memory exhaustion from oversized payloads + var body []byte + if r.Body != nil { + limitedReader := io.LimitReader(r.Body, maxBodyBytes+1) + body, err = io.ReadAll(limitedReader) + if err != nil { + m.sendUnauthorizedResponse(w, fmt.Errorf("failed to read request body")) + return + } + if int64(len(body)) > maxBodyBytes { + m.sendUnauthorizedResponse(w, fmt.Errorf("request body too large")) + return + } + // Restore body for downstream handlers + r.Body = io.NopCloser(bytes.NewBuffer(body)) + } + + // Try to parse as JSON-RPC request (only if we have body content) + if len(body) > 0 { + var jsonRPCReq struct { + Method string `json:"method"` + Params struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + } `json:"params"` + } + if err := json.Unmarshal(body, &jsonRPCReq); err == nil && jsonRPCReq.Method != "" { + // Check method-level scopes + var methodScopes []string + switch jsonRPCReq.Method { + case "tools/list": + methodScopes = m.scopes.ToolsList + case "tools/call": + methodScopes = m.scopes.ToolsCall + } + if len(methodScopes) > 0 { + if err := m.validateScopesForRequest(claims, methodScopes); err != nil { + m.sendInsufficientScopeResponse(w, methodScopes, claims, err) + return + } + } + + // Built-in tool scope check (additive to tools_call gate) + if jsonRPCReq.Method == "tools/call" && jsonRPCReq.Params.Name != "" { + if builtinScopes := m.getBuiltinToolScopes(jsonRPCReq.Params.Name); len(builtinScopes) > 0 { + if err := m.validateScopesForRequest(claims, builtinScopes); err != nil { + m.sendInsufficientScopeResponse(w, builtinScopes, claims, err) + return + } + } + } + + // Per-tool scope check for tools/call (additive to static tools_call gate) + if jsonRPCReq.Method == "tools/call" && jsonRPCReq.Params.Name != "" { + if toolOrScopes := m.getToolScopes(jsonRPCReq.Params.Name); len(toolOrScopes) > 0 { + tokenScopes := extractScopes(claims) + if !SatisfiesAnyGroup(tokenScopes, toolOrScopes) { + challengeScopes := BestScopeChallengeWithExisting(tokenScopes, toolOrScopes, m.scopeChallengeIncludeTokenScopes) + m.sendPerToolInsufficientScopeResponse(w, challengeScopes, jsonRPCReq.Params.Name) + return + } + } + + // Runtime scope check for execute_graphql: parse the query from arguments + // and extract @requiresScopes at the HTTP level (proper 403 + WWW-Authenticate) + if jsonRPCReq.Params.Name == "execute_graphql" && len(jsonRPCReq.Params.Arguments) > 0 { + if extractor := m.getScopeExtractor(); extractor != nil { + if challengeScopes := m.checkExecuteGraphQLScopes(claims, jsonRPCReq.Params.Arguments, extractor); len(challengeScopes) > 0 { + m.sendPerToolInsufficientScopeResponse(w, challengeScopes, "execute_graphql") + return + } + } + } + } + } + } + + // Add claims and request headers to request context for downstream handlers + ctx := context.WithValue(r.Context(), userClaimsContextKey, claims) + ctx = requestHeadersFromRequest(ctx, r) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// sendUnauthorizedResponse sends a 401 Unauthorized response with proper headers. +// It includes the minimum required scopes (from initialize) so that the MCP SDK +// can request exactly the scopes needed to establish a connection. +func (m *MCPAuthMiddleware) sendUnauthorizedResponse(w http.ResponseWriter, err error) { + // Build WWW-Authenticate header per RFC 6750 and RFC 9728 + authHeader := `Bearer realm="mcp"` + + // Include minimum required scopes (initialize scopes) so the client knows + // what scopes to request for initial authentication + if len(m.scopes.Initialize) > 0 { + authHeader += fmt.Sprintf(`, scope="%s"`, strings.Join(m.scopes.Initialize, " ")) + } + + // Add resource_metadata per RFC 9728 for OAuth discovery + if m.resourceMetadataURL != "" { + authHeader += fmt.Sprintf(`, resource_metadata="%s"`, m.resourceMetadataURL) + } + + // Add optional error_description for debugging + if err != nil { + authHeader += fmt.Sprintf(`, error_description="%s"`, err.Error()) + } + + w.Header().Set("WWW-Authenticate", authHeader) + w.WriteHeader(http.StatusUnauthorized) + + // Per MCP spec: Authorization failures at HTTP level return only HTTP status and WWW-Authenticate header + // No JSON-RPC response body is returned +} + +// sendInsufficientScopeResponse sends a 403 Forbidden response per RFC 6750 Section 3.1 +// when the token is valid but lacks required scopes. +// +// When scopeChallengeIncludeTokenScopes is false (default), only the scopes required for the +// operation are returned (RFC 6750 strict). When true, the token's existing scopes are unioned +// with the required scopes to work around client SDKs that replace rather than accumulate scopes. +func (m *MCPAuthMiddleware) sendInsufficientScopeResponse(w http.ResponseWriter, operationScopes []string, claims authentication.Claims, err error) { + challengeScopes := operationScopes + + if m.scopeChallengeIncludeTokenScopes { + // Union of token's existing scopes + operation's required scopes. + // Existing scopes come first so the client retains them on re-auth. + existing := extractScopes(claims) + seen := make(map[string]struct{}, len(existing)+len(operationScopes)) + combined := make([]string, 0, len(existing)+len(operationScopes)) + for _, s := range existing { + seen[s] = struct{}{} + combined = append(combined, s) + } + for _, s := range operationScopes { + if _, ok := seen[s]; !ok { + seen[s] = struct{}{} + combined = append(combined, s) + } + } + challengeScopes = combined + } + + scopeList := strings.Join(challengeScopes, " ") + + // Build WWW-Authenticate header with error and scope information + // Per RFC 6750 Section 3.1 and MCP spec: error, scope, resource_metadata, error_description + authHeader := fmt.Sprintf(`Bearer error="insufficient_scope", scope="%s"`, scopeList) + + // Add resource_metadata per MCP spec (should be included per spec line 513) + if m.resourceMetadataURL != "" { + authHeader += fmt.Sprintf(`, resource_metadata="%s"`, m.resourceMetadataURL) + } + + // Add optional error_description for human-readable message + if err != nil { + authHeader += fmt.Sprintf(`, error_description="%s"`, err.Error()) + } + + w.Header().Set("WWW-Authenticate", authHeader) + w.WriteHeader(http.StatusForbidden) + + // Per MCP spec: Authorization failures at HTTP level return only HTTP status and WWW-Authenticate header + // No JSON-RPC response body is returned +} + +// sendPerToolInsufficientScopeResponse sends a 403 response for per-tool scope failures. +// The challengeScopes have already been computed by BestScopeChallengeWithExisting. +func (m *MCPAuthMiddleware) sendPerToolInsufficientScopeResponse(w http.ResponseWriter, challengeScopes []string, toolName string) { + scopeList := strings.Join(challengeScopes, " ") + + authHeader := fmt.Sprintf(`Bearer error="insufficient_scope", scope="%s"`, scopeList) + + if m.resourceMetadataURL != "" { + authHeader += fmt.Sprintf(`, resource_metadata="%s"`, m.resourceMetadataURL) + } + + authHeader += fmt.Sprintf(`, error_description="insufficient scopes for tool %s"`, toolName) + + w.Header().Set("WWW-Authenticate", authHeader) + w.WriteHeader(http.StatusForbidden) +} + +// checkExecuteGraphQLScopes parses the GraphQL query from execute_graphql arguments, +// extracts @requiresScopes requirements, and returns the challenge scopes if insufficient. +// Returns nil if scopes are satisfied or the query cannot be parsed. +func (m *MCPAuthMiddleware) checkExecuteGraphQLScopes(claims authentication.Claims, arguments json.RawMessage, extractor *ScopeExtractor) []string { + var args struct { + Query string `json:"query"` + } + if err := json.Unmarshal(arguments, &args); err != nil || args.Query == "" { + return nil + } + + opDoc, report := astparser.ParseGraphqlDocumentString(args.Query) + if report.HasErrors() { + return nil // let the tool handler deal with parse errors + } + + fieldReqs := extractor.ExtractScopesForOperation(&opDoc) + if len(fieldReqs) == 0 { + return nil + } + + combinedScopes := extractor.ComputeCombinedScopes(fieldReqs) + if len(combinedScopes) == 0 { + return nil + } + + tokenScopes := extractScopes(claims) + if SatisfiesAnyGroup(tokenScopes, combinedScopes) { + return nil + } + + return BestScopeChallengeWithExisting(tokenScopes, combinedScopes, m.scopeChallengeIncludeTokenScopes) +} + +// validateScopesForRequest checks if the token contains all required scopes +func (m *MCPAuthMiddleware) validateScopesForRequest(claims authentication.Claims, requiredScopes []string) error { + // If no scopes are required, skip validation + if len(requiredScopes) == 0 { + return nil + } + + // Extract scopes from claims + tokenScopes := extractScopes(claims) + + // Check if all required scopes are present + var missingScopes []string + for _, requiredScope := range requiredScopes { + if !contains(tokenScopes, requiredScope) { + missingScopes = append(missingScopes, requiredScope) + } + } + + if len(missingScopes) > 0 { + return fmt.Errorf("missing required scopes: %s", strings.Join(missingScopes, ", ")) + } + + return nil +} + +// extractScopes extracts scope values from JWT claims +// Supports only the OAuth 2.0 standard "scope" claim as a space-separated string +func extractScopes(claims authentication.Claims) []string { + // Check for "scope" claim (OAuth 2.0 standard - space-separated string) + scopeClaim, ok := claims["scope"] + if !ok { + return []string{} + } + + // Only support string format per OAuth 2.0 spec + scopeStr, ok := scopeClaim.(string) + if !ok { + return []string{} + } + + // Use Fields() to split on any whitespace (spaces, tabs, newlines) + // and automatically filter out empty strings + return strings.Fields(scopeStr) +} + +// contains checks if a slice contains a specific string +func contains(slice []string, item string) bool { + return slices.Contains(slice, item) +} + +// GetClaimsFromContext retrieves authenticated user claims from context +func GetClaimsFromContext(ctx context.Context) (authentication.Claims, bool) { + claims, ok := ctx.Value(userClaimsContextKey).(authentication.Claims) + return claims, ok +} diff --git a/router/pkg/mcpserver/auth_middleware_test.go b/router/pkg/mcpserver/auth_middleware_test.go new file mode 100644 index 0000000000..165628a047 --- /dev/null +++ b/router/pkg/mcpserver/auth_middleware_test.go @@ -0,0 +1,708 @@ +package mcpserver + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/wundergraph/cosmo/router/pkg/authentication" +) + +// mockTokenDecoder is a mock implementation of authentication.TokenDecoder for testing +type mockTokenDecoder struct { + decodeFunc func(token string) (authentication.Claims, error) +} + +func (m *mockTokenDecoder) Decode(token string) (authentication.Claims, error) { + if m.decodeFunc != nil { + return m.decodeFunc(token) + } + return nil, errors.New("decode not implemented") +} + +func TestNewMCPAuthMiddleware(t *testing.T) { + validDecoder := &mockTokenDecoder{ + decodeFunc: func(token string) (authentication.Claims, error) { + return authentication.Claims{"sub": "user123"}, nil + }, + } + + tests := []struct { + name string + decoder authentication.TokenDecoder + enabled bool + wantErr bool + }{ + { + name: "valid decoder and enabled", + decoder: validDecoder, + enabled: true, + wantErr: false, + }, + { + name: "valid decoder and disabled", + decoder: validDecoder, + enabled: false, + wantErr: false, + }, + { + name: "nil decoder", + decoder: nil, + enabled: true, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + middleware, err := NewMCPAuthMiddleware(tt.decoder, tt.enabled, "http://localhost:5025/.well-known/oauth-protected-resource/mcp", MCPScopeConfig{}, false) + if tt.wantErr { + assert.Error(t, err) + assert.Nil(t, middleware) + } else { + assert.NoError(t, err) + assert.NotNil(t, middleware) + } + }) + } +} + +func TestGetClaimsFromContext(t *testing.T) { + expectedClaims := authentication.Claims{"sub": "user123", "email": "user@example.com"} + + tests := []struct { + name string + setupCtx func() context.Context + wantOk bool + wantClaims authentication.Claims + }{ + { + name: "claims present", + setupCtx: func() context.Context { + return context.WithValue(context.Background(), userClaimsContextKey, expectedClaims) + }, + wantOk: true, + wantClaims: expectedClaims, + }, + { + name: "claims absent", + setupCtx: func() context.Context { + return context.Background() + }, + wantOk: false, + wantClaims: nil, + }, + { + name: "wrong type", + setupCtx: func() context.Context { + return context.WithValue(context.Background(), userClaimsContextKey, "not-claims") + }, + wantOk: false, + wantClaims: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + claims, ok := GetClaimsFromContext(tt.setupCtx()) + assert.Equal(t, tt.wantOk, ok) + assert.Equal(t, tt.wantClaims, claims) + }) + } +} + +func TestExtractScopes(t *testing.T) { + tests := []struct { + name string + claims authentication.Claims + want []string + }{ + { + name: "scope with multiple values", + claims: authentication.Claims{ + "scope": "mcp:tools mcp:read mcp:write", + }, + want: []string{"mcp:tools", "mcp:read", "mcp:write"}, + }, + { + name: "scope with single value", + claims: authentication.Claims{ + "scope": "mcp:tools", + }, + want: []string{"mcp:tools"}, + }, + { + name: "no scope claim", + claims: authentication.Claims{}, + want: []string{}, + }, + { + name: "empty scope string", + claims: authentication.Claims{ + "scope": "", + }, + want: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractScopes(tt.claims) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestMCPAuthMiddleware_HTTPMiddleware(t *testing.T) { + t.Parallel() + + const testMetadataURL = "http://localhost:5025/.well-known/oauth-protected-resource/mcp" + + tests := []struct { + name string + scopes MCPScopeConfig + setupDecoder func() *mockTokenDecoder + setupRequest func() *http.Request + wantStatusCode int + wantWWWAuthenticatePrefix string + }{ + { + name: "valid token without scopes", + scopes: MCPScopeConfig{}, + setupDecoder: func() *mockTokenDecoder { + return &mockTokenDecoder{ + decodeFunc: func(token string) (authentication.Claims, error) { + if token == "valid-token" { + return authentication.Claims{"sub": "user123"}, nil + } + return nil, errors.New("invalid token") + }, + } + }, + setupRequest: func() *http.Request { + req, _ := http.NewRequest("POST", "/mcp", nil) + req.Header.Set("Authorization", "Bearer valid-token") + return req + }, + wantStatusCode: 200, + }, + { + name: "missing auth header - 401 includes init scopes", + scopes: MCPScopeConfig{Initialize: []string{"mcp:connect"}}, + setupDecoder: func() *mockTokenDecoder { + return &mockTokenDecoder{ + decodeFunc: func(token string) (authentication.Claims, error) { + return nil, errors.New("missing authorization header") + }, + } + }, + setupRequest: func() *http.Request { + req, _ := http.NewRequest("POST", "/mcp", nil) + return req + }, + wantStatusCode: 401, + wantWWWAuthenticatePrefix: `Bearer realm="mcp", scope="mcp:connect", resource_metadata="` + testMetadataURL + `"`, + }, + { + name: "missing auth header - 401 without scopes when none configured", + scopes: MCPScopeConfig{}, + setupDecoder: func() *mockTokenDecoder { + return &mockTokenDecoder{ + decodeFunc: func(token string) (authentication.Claims, error) { + return nil, errors.New("missing authorization header") + }, + } + }, + setupRequest: func() *http.Request { + req, _ := http.NewRequest("POST", "/mcp", nil) + return req + }, + wantStatusCode: 401, + wantWWWAuthenticatePrefix: `Bearer realm="mcp", resource_metadata="` + testMetadataURL + `"`, + }, + { + name: "invalid token - 401 includes init scopes", + scopes: MCPScopeConfig{Initialize: []string{"mcp:connect"}}, + setupDecoder: func() *mockTokenDecoder { + return &mockTokenDecoder{ + decodeFunc: func(token string) (authentication.Claims, error) { + return nil, errors.New("token validation failed") + }, + } + }, + setupRequest: func() *http.Request { + req, _ := http.NewRequest("POST", "/mcp", nil) + req.Header.Set("Authorization", "Bearer invalid-token") + return req + }, + wantStatusCode: 401, + wantWWWAuthenticatePrefix: `Bearer realm="mcp", scope="mcp:connect", resource_metadata="` + testMetadataURL + `"`, + }, + { + name: "insufficient init scopes - 403 with include token scopes enabled", + scopes: MCPScopeConfig{Initialize: []string{"mcp:connect"}}, + setupDecoder: func() *mockTokenDecoder { + return &mockTokenDecoder{ + decodeFunc: func(token string) (authentication.Claims, error) { + if token == "valid-token" { + return authentication.Claims{ + "sub": "user123", + "scope": "mcp:tools:read", + }, nil + } + return nil, errors.New("invalid token") + }, + } + }, + setupRequest: func() *http.Request { + req, _ := http.NewRequest("POST", "/mcp", nil) + req.Header.Set("Authorization", "Bearer valid-token") + return req + }, + wantStatusCode: 403, + wantWWWAuthenticatePrefix: `Bearer error="insufficient_scope", scope="mcp:tools:read mcp:connect"`, + }, + { + name: "valid token with all required scopes", + scopes: MCPScopeConfig{ + Initialize: []string{"mcp:connect"}, + }, + setupDecoder: func() *mockTokenDecoder { + return &mockTokenDecoder{ + decodeFunc: func(token string) (authentication.Claims, error) { + if token == "valid-token" { + return authentication.Claims{ + "sub": "user123", + "scope": "mcp:connect mcp:tools:read mcp:tools:write", + }, nil + } + return nil, errors.New("invalid token") + }, + } + }, + setupRequest: func() *http.Request { + req, _ := http.NewRequest("POST", "/mcp", nil) + req.Header.Set("Authorization", "Bearer valid-token") + return req + }, + wantStatusCode: 200, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + decoder := tt.setupDecoder() + middleware, err := NewMCPAuthMiddleware(decoder, true, testMetadataURL, tt.scopes, true) + assert.NoError(t, err) + + handler := middleware.HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + + req := tt.setupRequest() + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, tt.wantStatusCode, rr.Code) + if tt.wantWWWAuthenticatePrefix != "" { + wwwAuth := rr.Header().Get("WWW-Authenticate") + assert.Contains(t, wwwAuth, tt.wantWWWAuthenticatePrefix) + } + }) + } +} + +func TestMCPAuthMiddleware_PerToolScopes(t *testing.T) { + t.Parallel() + + const testMetadataURL = "http://localhost:5025/.well-known/oauth-protected-resource/mcp" + + validDecoder := &mockTokenDecoder{ + decodeFunc: func(token string) (authentication.Claims, error) { + switch token { + case "no-scopes": + return authentication.Claims{"sub": "user1", "scope": "mcp:connect mcp:tools:write"}, nil + case "has-read-fact": + return authentication.Claims{"sub": "user2", "scope": "mcp:connect mcp:tools:write read:fact"}, nil + case "has-read-all": + return authentication.Claims{"sub": "user3", "scope": "mcp:connect mcp:tools:write read:all"}, nil + case "has-read-employee": + return authentication.Claims{"sub": "user4", "scope": "mcp:connect mcp:tools:write read:employee"}, nil + case "has-read-employee-private": + return authentication.Claims{"sub": "user5", "scope": "mcp:connect mcp:tools:write read:employee read:private"}, nil + default: + return nil, errors.New("invalid token") + } + }, + } + + scopes := MCPScopeConfig{ + Initialize: []string{"mcp:connect"}, + ToolsCall: []string{"mcp:tools:write"}, + } + + // Tool scopes simulating @requiresScopes extraction + toolScopes := map[string][][]string{ + "execute_operation_get_top_secret_facts": { + {"read:fact"}, + {"read:all"}, + }, + "execute_operation_get_employee_start_date": { + {"read:employee", "read:private"}, + {"read:all"}, + }, + // execute_operation_list_employees has no scopes (not in map) + } + + tests := []struct { + name string + token string + body string + scopeChallengeIncludeTokenScopes bool + wantStatusCode int + wantScope string + wantContains string // additional WWW-Authenticate check + }{ + { + name: "unscoped tool passes with just static scopes", + token: "no-scopes", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_list_employees"}}`, + wantStatusCode: 200, + }, + { + name: "scoped tool - token has matching scope (read:fact)", + token: "has-read-fact", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_top_secret_facts"}}`, + wantStatusCode: 200, + }, + { + name: "scoped tool - token has matching scope (read:all)", + token: "has-read-all", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_top_secret_facts"}}`, + wantStatusCode: 200, + }, + { + name: "scoped tool - token lacks scopes, challenge picks smallest group", + token: "no-scopes", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_top_secret_facts"}}`, + wantStatusCode: 403, + wantScope: `scope="read:fact"`, + wantContains: `error_description="insufficient scopes for tool execute_operation_get_top_secret_facts"`, + }, + { + name: "scoped tool - include token scopes in challenge", + token: "no-scopes", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_top_secret_facts"}}`, + scopeChallengeIncludeTokenScopes: true, + wantStatusCode: 403, + wantScope: `scope="mcp:connect mcp:tools:write read:fact"`, + }, + { + name: "AND group - token has one of two required, challenge picks closest group", + token: "has-read-employee", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_employee_start_date"}}`, + wantStatusCode: 403, + // Group 1: ["read:employee", "read:private"] missing "read:private" (1 missing) + // Group 2: ["read:all"] missing "read:all" (1 missing) + // Tie → first group wins + wantScope: `scope="read:employee read:private"`, + }, + { + name: "AND group - token satisfies full AND group", + token: "has-read-employee-private", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_employee_start_date"}}`, + wantStatusCode: 200, + }, + { + name: "AND group - token has read:all satisfies second OR group", + token: "has-read-all", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_employee_start_date"}}`, + wantStatusCode: 200, + }, + { + name: "AND group - empty relevant scopes, challenge picks smallest group", + token: "no-scopes", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_employee_start_date"}}`, + wantStatusCode: 403, + // Group 1: 2 missing, Group 2: 1 missing → Group 2 wins + wantScope: `scope="read:all"`, + }, + { + name: "tools/list is not affected by per-tool scopes", + token: "no-scopes", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`, + wantStatusCode: 200, + }, + { + name: "unknown tool name passes (no per-tool scopes)", + token: "no-scopes", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"unknown_tool"}}`, + wantStatusCode: 200, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + middleware, err := NewMCPAuthMiddleware(validDecoder, true, testMetadataURL, scopes, tt.scopeChallengeIncludeTokenScopes) + assert.NoError(t, err) + + // Set per-tool scopes + middleware.SetToolScopes(toolScopes) + + handler := middleware.HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + + req, _ := http.NewRequest("POST", "/mcp", strings.NewReader(tt.body)) + req.Header.Set("Authorization", "Bearer "+tt.token) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, tt.wantStatusCode, rr.Code) + if tt.wantScope != "" { + wwwAuth := rr.Header().Get("WWW-Authenticate") + assert.Contains(t, wwwAuth, tt.wantScope, "WWW-Authenticate header should contain expected scope") + } + if tt.wantContains != "" { + wwwAuth := rr.Header().Get("WWW-Authenticate") + assert.Contains(t, wwwAuth, tt.wantContains, "WWW-Authenticate header should contain expected string") + } + }) + } +} + +func TestMCPAuthMiddleware_MethodLevelScopes(t *testing.T) { + t.Parallel() + + const testMetadataURL = "http://localhost:5025/.well-known/oauth-protected-resource/mcp" + + validDecoder := &mockTokenDecoder{ + decodeFunc: func(token string) (authentication.Claims, error) { + if token == "connect-only" { + return authentication.Claims{ + "sub": "user123", + "scope": "mcp:connect", + }, nil + } + if token == "connect-and-read" { + return authentication.Claims{ + "sub": "user123", + "scope": "mcp:connect mcp:tools:read", + }, nil + } + if token == "all-scopes" { + return authentication.Claims{ + "sub": "user123", + "scope": "mcp:connect mcp:tools:read mcp:tools:write", + }, nil + } + return nil, errors.New("invalid token") + }, + } + + scopes := MCPScopeConfig{ + Initialize: []string{"mcp:connect"}, + ToolsList: []string{"mcp:tools:read"}, + ToolsCall: []string{"mcp:tools:write"}, + } + + tests := []struct { + name string + token string + body string + scopeChallengeIncludeTokenScopes bool + wantStatusCode int + wantScope string // expected scope value in WWW-Authenticate, empty if not checked + }{ + { + name: "tools/list with insufficient scopes - default returns operation scopes only", + token: "connect-only", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`, + scopeChallengeIncludeTokenScopes: false, + wantStatusCode: 403, + wantScope: `scope="mcp:tools:read"`, + }, + { + name: "tools/list with insufficient scopes - include token scopes", + token: "connect-only", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`, + scopeChallengeIncludeTokenScopes: true, + wantStatusCode: 403, + wantScope: `scope="mcp:connect mcp:tools:read"`, + }, + { + name: "tools/list with sufficient scopes succeeds", + token: "connect-and-read", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`, + scopeChallengeIncludeTokenScopes: false, + wantStatusCode: 200, + }, + { + name: "tools/call with insufficient scopes - default returns operation scopes only", + token: "connect-and-read", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_graphql"}}`, + scopeChallengeIncludeTokenScopes: false, + wantStatusCode: 403, + wantScope: `scope="mcp:tools:write"`, + }, + { + name: "tools/call with insufficient scopes - include token scopes", + token: "connect-and-read", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_graphql"}}`, + scopeChallengeIncludeTokenScopes: true, + wantStatusCode: 403, + wantScope: `scope="mcp:connect mcp:tools:read mcp:tools:write"`, + }, + { + name: "tools/call with all scopes succeeds", + token: "all-scopes", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_graphql"}}`, + scopeChallengeIncludeTokenScopes: false, + wantStatusCode: 200, + }, + { + name: "unknown method with no scope requirements succeeds", + token: "connect-only", + body: `{"jsonrpc":"2.0","id":1,"method":"ping"}`, + scopeChallengeIncludeTokenScopes: false, + wantStatusCode: 200, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + middleware, err := NewMCPAuthMiddleware(validDecoder, true, testMetadataURL, scopes, tt.scopeChallengeIncludeTokenScopes) + assert.NoError(t, err) + + handler := middleware.HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + + req, _ := http.NewRequest("POST", "/mcp", strings.NewReader(tt.body)) + req.Header.Set("Authorization", "Bearer "+tt.token) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, tt.wantStatusCode, rr.Code) + if tt.wantScope != "" { + wwwAuth := rr.Header().Get("WWW-Authenticate") + assert.Contains(t, wwwAuth, tt.wantScope) + } + }) + } +} + +func TestMCPAuthMiddleware_BuiltinToolScopes(t *testing.T) { + t.Parallel() + + const testMetadataURL = "http://localhost:5025/.well-known/oauth-protected-resource/mcp" + + validDecoder := &mockTokenDecoder{ + decodeFunc: func(token string) (authentication.Claims, error) { + switch token { + case "base-only": + return authentication.Claims{"sub": "user1", "scope": "mcp:connect mcp:tools:call"}, nil + case "has-schema-read": + return authentication.Claims{"sub": "user2", "scope": "mcp:connect mcp:tools:call mcp:schema:read"}, nil + case "has-graphql-execute": + return authentication.Claims{"sub": "user3", "scope": "mcp:connect mcp:tools:call mcp:graphql:execute"}, nil + case "has-ops-read": + return authentication.Claims{"sub": "user4", "scope": "mcp:connect mcp:tools:call mcp:ops:read"}, nil + default: + return nil, errors.New("invalid token") + } + }, + } + + scopes := MCPScopeConfig{ + Initialize: []string{"mcp:connect"}, + ToolsCall: []string{"mcp:tools:call"}, + ExecuteGraphQL: []string{"mcp:graphql:execute"}, + GetOperationInfo: []string{"mcp:ops:read"}, + GetSchema: []string{"mcp:schema:read"}, + } + + tests := []struct { + name string + token string + body string + wantStatusCode int + wantScope string + }{ + { + name: "execute_graphql without required scope returns 403", + token: "base-only", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_graphql"}}`, + wantStatusCode: 403, + wantScope: `scope="mcp:graphql:execute"`, + }, + { + name: "execute_graphql with required scope passes", + token: "has-graphql-execute", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_graphql"}}`, + wantStatusCode: 200, + }, + { + name: "get_schema without required scope returns 403", + token: "base-only", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_schema"}}`, + wantStatusCode: 403, + wantScope: `scope="mcp:schema:read"`, + }, + { + name: "get_schema with required scope passes", + token: "has-schema-read", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_schema"}}`, + wantStatusCode: 200, + }, + { + name: "get_operation_info without required scope returns 403", + token: "base-only", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_operation_info"}}`, + wantStatusCode: 403, + wantScope: `scope="mcp:ops:read"`, + }, + { + name: "get_operation_info with required scope passes", + token: "has-ops-read", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_operation_info"}}`, + wantStatusCode: 200, + }, + { + name: "non-builtin tool is not affected by builtin scopes", + token: "base-only", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_users"}}`, + wantStatusCode: 200, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + middleware, err := NewMCPAuthMiddleware(validDecoder, true, testMetadataURL, scopes, false) + assert.NoError(t, err) + + handler := middleware.HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + + req, _ := http.NewRequest("POST", "/mcp", strings.NewReader(tt.body)) + req.Header.Set("Authorization", "Bearer "+tt.token) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, tt.wantStatusCode, rr.Code) + if tt.wantScope != "" { + wwwAuth := rr.Header().Get("WWW-Authenticate") + assert.Contains(t, wwwAuth, tt.wantScope, "WWW-Authenticate header should contain expected scope") + } + }) + } +} diff --git a/router/pkg/mcpserver/errors.go b/router/pkg/mcpserver/errors.go new file mode 100644 index 0000000000..e91a64cf88 --- /dev/null +++ b/router/pkg/mcpserver/errors.go @@ -0,0 +1,34 @@ +package mcpserver + +// JSON-RPC 2.0 and MCP error codes +// +// Error code ranges: +// - Standard JSON-RPC 2.0: -32768 to -32000 (reserved by JSON-RPC spec) +// - Server errors (implementation-defined): -32000 to -32099 (within JSON-RPC reserved range) +// - Application errors: Must use codes outside -32768 to -32000 to avoid conflicts with JSON-RPC reserved codes +const ( + // Standard JSON-RPC 2.0 error codes + ErrorCodeParseError = -32700 // Invalid JSON was received by the server + ErrorCodeInvalidRequest = -32600 // The JSON sent is not a valid Request object + ErrorCodeMethodNotFound = -32601 // The method does not exist / is not available + ErrorCodeInvalidParams = -32602 // Invalid method parameter(s) + ErrorCodeInternalError = -32603 // Internal JSON-RPC error + + // MCP-specific error codes (from MCP specification) + // See: https://spec.modelcontextprotocol.io/specification/basic/errors/ + ErrorCodeResourceNotFound = -32002 // Requested resource was not found + + // Custom Cosmo MCP server error codes + // These use the reserved range -32000 to -32099 for implementation-defined server errors + ErrorCodeAuthenticationRequired = -32001 // Authentication required (OAuth/JWT) + ErrorCodeInsufficientScope = -32003 // Token lacks required OAuth scopes (RFC 6750) +) + +// Error messages +const ( + ErrorMessageAuthenticationRequired = "Authentication required" + ErrorMessageInsufficientScope = "Insufficient scope" + ErrorMessageResourceNotFound = "Resource not found" + ErrorMessageInvalidParams = "Invalid params" + ErrorMessageInternalError = "Internal error" +) diff --git a/router/pkg/mcpserver/execute_graphql_scope_test.go b/router/pkg/mcpserver/execute_graphql_scope_test.go new file mode 100644 index 0000000000..3f8e75f865 --- /dev/null +++ b/router/pkg/mcpserver/execute_graphql_scope_test.go @@ -0,0 +1,204 @@ +package mcpserver + +import ( + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/wundergraph/cosmo/router/pkg/authentication" +) + +func TestMCPAuthMiddleware_ExecuteGraphQLScopes(t *testing.T) { + t.Parallel() + + const testMetadataURL = "http://localhost:5025/.well-known/oauth-protected-resource/mcp" + + schema := parseTestSchema(t) + fieldConfigs := testFieldConfigs() + extractor := NewScopeExtractor(fieldConfigs, &schema) + + validDecoder := &mockTokenDecoder{ + decodeFunc: func(token string) (authentication.Claims, error) { + switch token { + case "no-extra-scopes": + return authentication.Claims{"sub": "user1", "scope": "mcp:connect mcp:tools:write"}, nil + case "has-read-fact": + return authentication.Claims{"sub": "user2", "scope": "mcp:connect mcp:tools:write read:fact"}, nil + case "has-read-all": + return authentication.Claims{"sub": "user3", "scope": "mcp:connect mcp:tools:write read:all"}, nil + case "has-read-employee": + return authentication.Claims{"sub": "user4", "scope": "mcp:connect mcp:tools:write read:employee"}, nil + case "has-read-employee-private": + return authentication.Claims{"sub": "user5", "scope": "mcp:connect mcp:tools:write read:employee read:private"}, nil + case "has-mcp-connect": + return authentication.Claims{"sub": "user6", "scope": "mcp:connect mcp:tools:write"}, nil + default: + return nil, errors.New("invalid token") + } + }, + } + + scopes := MCPScopeConfig{ + Initialize: []string{"mcp:connect"}, + ToolsCall: []string{"mcp:tools:write"}, + } + + makeBody := func(query string) string { + // Escape quotes in query for JSON + escaped := strings.ReplaceAll(query, `"`, `\"`) + return `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_graphql","arguments":{"query":"` + escaped + `"}}}` + } + + tests := []struct { + name string + token string + query string + scopeChallengeIncludeTokenScopes bool + wantStatusCode int + wantScope string + wantContains string + }{ + { + name: "unscoped query passes", + token: "no-extra-scopes", + query: `query { employees { id tag } }`, + wantStatusCode: 200, + }, + { + name: "scoped query with matching scope passes", + token: "has-read-fact", + query: `query { topSecretFederationFacts { ... on DirectiveFact { title } } }`, + wantStatusCode: 200, + }, + { + name: "scoped query with alternative scope (read:all) passes", + token: "has-read-all", + query: `query { topSecretFederationFacts { ... on DirectiveFact { title } } }`, + wantStatusCode: 200, + }, + { + name: "scoped query without required scope returns 403", + token: "no-extra-scopes", + query: `query { topSecretFederationFacts { ... on DirectiveFact { title } } }`, + wantStatusCode: 403, + wantScope: `scope="read:fact"`, + wantContains: `error_description="insufficient scopes for tool execute_graphql"`, + }, + { + name: "AND scopes - token has one of two required", + token: "has-read-employee", + query: `query { employee(id: 1) { id startDate } }`, + wantStatusCode: 403, + wantScope: `scope="read:employee read:private"`, + }, + { + name: "AND scopes - token satisfies full AND group", + token: "has-read-employee-private", + query: `query { employee(id: 1) { id startDate } }`, + wantStatusCode: 200, + }, + { + name: "AND scopes - read:all satisfies alternative group", + token: "has-read-all", + query: `query { employee(id: 1) { id startDate } }`, + wantStatusCode: 200, + }, + { + name: "empty relevant scopes picks smallest group", + token: "no-extra-scopes", + query: `query { employee(id: 1) { id startDate } }`, + wantStatusCode: 403, + // Group 1: 2 missing, Group 2: 1 missing → Group 2 wins + wantScope: `scope="read:all"`, + }, + { + name: "include token scopes in challenge", + token: "has-mcp-connect", + query: `query { topSecretFederationFacts { ... on DirectiveFact { title } } }`, + scopeChallengeIncludeTokenScopes: true, + wantStatusCode: 403, + wantScope: `scope="mcp:connect mcp:tools:write read:fact"`, + }, + { + name: "invalid query passes through (not scope-checked)", + token: "no-extra-scopes", + query: `not a valid query {}`, + wantStatusCode: 200, + }, + { + name: "empty query passes through", + token: "no-extra-scopes", + query: ``, + wantStatusCode: 200, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + middleware, err := NewMCPAuthMiddleware(validDecoder, true, testMetadataURL, scopes, tt.scopeChallengeIncludeTokenScopes) + assert.NoError(t, err) + + // Set scope extractor for execute_graphql runtime checking + middleware.SetScopeExtractor(extractor) + + handler := middleware.HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + + req, _ := http.NewRequest("POST", "/mcp", strings.NewReader(makeBody(tt.query))) + req.Header.Set("Authorization", "Bearer "+tt.token) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, tt.wantStatusCode, rr.Code) + if tt.wantScope != "" { + wwwAuth := rr.Header().Get("WWW-Authenticate") + assert.Contains(t, wwwAuth, tt.wantScope, "WWW-Authenticate header should contain expected scope") + } + if tt.wantContains != "" { + wwwAuth := rr.Header().Get("WWW-Authenticate") + assert.Contains(t, wwwAuth, tt.wantContains, "WWW-Authenticate header should contain expected string") + } + }) + } +} + +func TestMCPAuthMiddleware_ExecuteGraphQLNoExtractor(t *testing.T) { + t.Parallel() + + const testMetadataURL = "http://localhost:5025/.well-known/oauth-protected-resource/mcp" + + decoder := &mockTokenDecoder{ + decodeFunc: func(token string) (authentication.Claims, error) { + return authentication.Claims{"sub": "user1", "scope": "mcp:connect mcp:tools:write"}, nil + }, + } + + scopes := MCPScopeConfig{ + Initialize: []string{"mcp:connect"}, + ToolsCall: []string{"mcp:tools:write"}, + } + + middleware, err := NewMCPAuthMiddleware(decoder, true, testMetadataURL, scopes, false) + assert.NoError(t, err) + // Deliberately NOT setting a scope extractor + + handler := middleware.HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + + // Scoped query should pass through when no extractor is set + body := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_graphql","arguments":{"query":"query { topSecretFederationFacts { ... on DirectiveFact { title } } }"}}}` + req, _ := http.NewRequest("POST", "/mcp", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer test") + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, 200, rr.Code, "should pass through when no scope extractor is configured") +} diff --git a/router/pkg/mcpserver/operation_manager.go b/router/pkg/mcpserver/operation_manager.go index 0bbe2e15d6..d1ab9833a3 100644 --- a/router/pkg/mcpserver/operation_manager.go +++ b/router/pkg/mcpserver/operation_manager.go @@ -3,9 +3,12 @@ package mcpserver import ( "fmt" + "go.uber.org/zap" + + nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" "github.com/wundergraph/cosmo/router/pkg/schemaloader" + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" - "go.uber.org/zap" ) // OperationsManager handles the loading and preparation of GraphQL operations @@ -83,6 +86,18 @@ func (om *OperationsManager) GetOperation(name string) *schemaloader.Operation { return nil } +// ComputeToolScopes runs the scope extractor against all loaded operations, +// populating each operation's RequiredScopes from @requiresScopes directives. +func (om *OperationsManager) ComputeToolScopes(fieldConfigs []*nodev1.FieldConfiguration) { + extractor := NewScopeExtractor(fieldConfigs, om.schemaDoc) + for i := range om.operations { + fieldReqs := extractor.ExtractScopesForOperation(&om.operations[i].Document) + if len(fieldReqs) > 0 { + om.operations[i].RequiredScopes = extractor.ComputeCombinedScopes(fieldReqs) + } + } +} + // GetSchema returns the schema document used by the operations manager func (om *OperationsManager) GetSchema() *ast.Document { return om.schemaDoc diff --git a/router/pkg/mcpserver/schema_compiler.go b/router/pkg/mcpserver/schema_compiler.go index 2bfcf79966..11816bd196 100644 --- a/router/pkg/mcpserver/schema_compiler.go +++ b/router/pkg/mcpserver/schema_compiler.go @@ -62,7 +62,7 @@ func (sc *SchemaCompiler) ValidateInput(data []byte, compiledSchema *jsonschema. return nil } - var v interface{} + var v any if err := json.Unmarshal(data, &v); err != nil { return fmt.Errorf("failed to parse JSON input: %w", err) } diff --git a/router/pkg/mcpserver/scope_challenge.go b/router/pkg/mcpserver/scope_challenge.go new file mode 100644 index 0000000000..29d4eacf7d --- /dev/null +++ b/router/pkg/mcpserver/scope_challenge.go @@ -0,0 +1,108 @@ +package mcpserver + +import "slices" + +// SatisfiesAnyGroup checks whether tokenScopes satisfies at least one AND-group +// in the OR-of-AND scope requirements. Returns true if no requirements exist. +func SatisfiesAnyGroup(tokenScopes []string, orScopes [][]string) bool { + if len(orScopes) == 0 { + return true + } + for _, andGroup := range orScopes { + if satisfiesAll(tokenScopes, andGroup) { + return true + } + } + return false +} + +// BestScopeChallenge picks the AND-group closest to the client's current scopes. +// Returns the complete AND-group that the client should request, or nil if any +// group is already satisfied. +// +// Algorithm: +// 1. For each AND-group, count how many scopes the token is missing. +// 2. If any group has 0 missing, return nil (already satisfied). +// 3. Pick the group with the fewest missing scopes. +// 4. On ties, pick the first group (stable ordering). +func BestScopeChallenge(tokenScopes []string, combinedOrScopes [][]string) []string { + if len(combinedOrScopes) == 0 { + return nil + } + + tokenSet := toSet(tokenScopes) + + bestIdx := -1 + bestMissing := -1 + + for i, andGroup := range combinedOrScopes { + missing := 0 + for _, scope := range andGroup { + if _, ok := tokenSet[scope]; !ok { + missing++ + } + } + if missing == 0 { + return nil + } + if bestIdx == -1 || missing < bestMissing { + bestIdx = i + bestMissing = missing + } + } + + return combinedOrScopes[bestIdx] +} + +// BestScopeChallengeWithExisting returns the challenge scopes, optionally including +// the token's existing scopes. When includeExisting is true, the result is the union +// of the token's current scopes and the best AND-group, deduplicated. This works +// around MCP client SDKs that replace rather than accumulate scopes on re-authorization. +func BestScopeChallengeWithExisting(tokenScopes []string, combinedOrScopes [][]string, includeExisting bool) []string { + best := BestScopeChallenge(tokenScopes, combinedOrScopes) + if best == nil { + return nil + } + + if !includeExisting { + return best + } + + // Union: token scopes first, then any scopes from the best group not already present + seen := make(map[string]struct{}, len(tokenScopes)+len(best)) + result := make([]string, 0, len(tokenScopes)+len(best)) + + for _, s := range tokenScopes { + if _, ok := seen[s]; !ok { + seen[s] = struct{}{} + result = append(result, s) + } + } + for _, s := range best { + if _, ok := seen[s]; !ok { + seen[s] = struct{}{} + result = append(result, s) + } + } + + return result +} + +// satisfiesAll returns true if tokenScopes contains every scope in required. +func satisfiesAll(tokenScopes []string, required []string) bool { + for _, r := range required { + if !slices.Contains(tokenScopes, r) { + return false + } + } + return true +} + +// toSet converts a string slice to a set for O(1) lookups. +func toSet(ss []string) map[string]struct{} { + m := make(map[string]struct{}, len(ss)) + for _, s := range ss { + m[s] = struct{}{} + } + return m +} diff --git a/router/pkg/mcpserver/scope_challenge.md b/router/pkg/mcpserver/scope_challenge.md new file mode 100644 index 0000000000..83bf410a42 --- /dev/null +++ b/router/pkg/mcpserver/scope_challenge.md @@ -0,0 +1,93 @@ +# Smart Scope Challenge Algorithm + +## Problem + +When an MCP client calls a tool but lacks the required OAuth scopes, the server must return a `403 Forbidden` with a `WWW-Authenticate` header containing a `scope` parameter. The client uses this to request the right scopes during re-authorization. + +The `@requiresScopes` directive uses **OR-of-AND** semantics — there may be multiple valid scope combinations that grant access. Rather than dumping all possible scopes, the server should guide the client toward the **closest satisfiable path** based on what scopes the token already has. + +## Scope Representation + +Scopes are represented as `[][]string` — a list of AND-groups where satisfying **any one** group grants access: + +``` +[["a", "b"], ["c", "d"]] → (a AND b) OR (c AND d) +``` + +When an operation touches multiple scoped fields, their requirements are combined via Cartesian product (see `scope_extractor.go`), producing a single `[][]string` for the tool. + +## Algorithm: `BestScopeChallenge` + +**Input:** +- `tokenScopes` — scopes the client's JWT currently has +- `combinedOrScopes` — the tool's OR-of-AND requirements + +**Steps:** + +1. If `combinedOrScopes` is empty, return `nil` (no requirements). +2. Build a set from `tokenScopes` for O(1) lookup. +3. For each AND-group, count how many scopes the token is **missing**. +4. If any group has **0 missing**, return `nil` — the token already satisfies the requirement. +5. Pick the group with the **fewest missing** scopes. On ties, pick the **first** group (stable ordering). +6. Return the complete AND-group as the challenge. + +**Why return the complete group, not just the missing scopes?** + +OAuth authorization requests specify the full set of scopes desired. The client needs the complete group to know what to request — not a diff. + +## Examples + +### Simple OR (single-scope groups) + +``` +Required: [["read:fact"], ["read:all"]] +``` + +| Token scopes | Missing per group | Best group | Challenge | +|---|---|---|---| +| `["read:fact"]` | 0, 1 | satisfied | `nil` | +| `["read:all"]` | 1, 0 | satisfied | `nil` | +| `[]` | 1, 1 | first (tie) | `["read:fact"]` | +| `["other"]` | 1, 1 | first (tie) | `["read:fact"]` | + +### AND group with shortcut + +``` +Required: [["read:employee", "read:private"], ["read:all"]] +``` + +| Token scopes | Missing per group | Best group | Challenge | +|---|---|---|---| +| `["read:employee", "read:private"]` | 0, 1 | satisfied | `nil` | +| `["read:employee"]` | 1, 1 | first (tie) | `["read:employee", "read:private"]` | +| `[]` | 2, 1 | group 2 | `["read:all"]` | + +### Cross-subgraph aggregation + +``` +Required: [ + ["read:fact", "read:employee", "read:private"], + ["read:fact", "read:all"], + ["read:all", "read:employee", "read:private"], + ["read:all"] +] +``` + +| Token scopes | Missing per group | Best group | Challenge | +|---|---|---|---| +| `["read:all"]` | 2, 1, 2, 0 | satisfied | `nil` | +| `["read:fact"]` | 2, 1, 2, 1 | group 2 (tie→first) | `["read:fact", "read:all"]` | +| `[]` | 3, 2, 3, 1 | group 4 | `["read:all"]` | + +## `BestScopeChallengeWithExisting` + +Some MCP client SDKs **replace** rather than **accumulate** scopes when re-authorizing. If the challenge only contains the scopes for the failed operation, the client loses its existing scopes. + +When `includeExisting` is `true`, the result is the **union** of the token's current scopes and the best AND-group, deduplicated, preserving order (token scopes first). + +Example: token has `["init", "mcp:tools:write", "a"]`, best group is `["a", "b", "d"]` +→ result: `["init", "mcp:tools:write", "a", "b", "d"]` + +## `SatisfiesAnyGroup` + +A simple check: does the token satisfy at least one AND-group? Returns `true` if requirements are empty/nil (no scopes needed). Used as the gate check before computing a challenge. \ No newline at end of file diff --git a/router/pkg/mcpserver/scope_challenge_test.go b/router/pkg/mcpserver/scope_challenge_test.go new file mode 100644 index 0000000000..ba2fa7347c --- /dev/null +++ b/router/pkg/mcpserver/scope_challenge_test.go @@ -0,0 +1,339 @@ +package mcpserver + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBestScopeChallenge(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tokenScopes []string + combinedOrScopes [][]string + want []string + }{ + // --- Simple OR scopes (single field, single-scope groups) --- + // e.g. Query.topSecretFederationFacts → [["read:fact"], ["read:all"]] + { + name: "simple OR: token satisfies first group", + tokenScopes: []string{"read:fact"}, + combinedOrScopes: [][]string{{"read:fact"}, {"read:all"}}, + want: nil, + }, + { + name: "simple OR: token satisfies second group", + tokenScopes: []string{"read:all"}, + combinedOrScopes: [][]string{{"read:fact"}, {"read:all"}}, + want: nil, + }, + { + name: "simple OR: empty token picks first group on tie", + tokenScopes: []string{}, + combinedOrScopes: [][]string{{"read:fact"}, {"read:all"}}, + want: []string{"read:fact"}, + }, + { + name: "simple OR: unrelated token picks first group on tie", + tokenScopes: []string{"read:other"}, + combinedOrScopes: [][]string{{"read:fact"}, {"read:all"}}, + want: []string{"read:fact"}, + }, + + // --- Mutation with simple OR scopes --- + // e.g. Mutation.addFact → [["write:fact"], ["write:all"]] + { + name: "mutation OR: token has write:fact passes", + tokenScopes: []string{"write:fact"}, + combinedOrScopes: [][]string{{"write:fact"}, {"write:all"}}, + want: nil, + }, + { + name: "mutation OR: token has write:all passes", + tokenScopes: []string{"write:all"}, + combinedOrScopes: [][]string{{"write:fact"}, {"write:all"}}, + want: nil, + }, + { + name: "mutation OR: wrong category scope picks first group", + tokenScopes: []string{"read:fact"}, + combinedOrScopes: [][]string{{"write:fact"}, {"write:all"}}, + want: []string{"write:fact"}, + }, + { + name: "mutation OR: empty token picks first group", + tokenScopes: []string{}, + combinedOrScopes: [][]string{{"write:fact"}, {"write:all"}}, + want: []string{"write:fact"}, + }, + + // --- AND scopes with OR alternative --- + // e.g. Employee.startDate → [["read:employee", "read:private"], ["read:all"]] + { + name: "AND group: token satisfies full AND group", + tokenScopes: []string{"read:employee", "read:private"}, + combinedOrScopes: [][]string{{"read:employee", "read:private"}, {"read:all"}}, + want: nil, + }, + { + name: "AND group: token satisfies shortcut group", + tokenScopes: []string{"read:all"}, + combinedOrScopes: [][]string{{"read:employee", "read:private"}, {"read:all"}}, + want: nil, + }, + { + name: "AND group: partial match picks first group on tie (1 missing each)", + tokenScopes: []string{"read:employee"}, + combinedOrScopes: [][]string{{"read:employee", "read:private"}, {"read:all"}}, + want: []string{"read:employee", "read:private"}, + }, + { + name: "AND group: other partial match also picks first on tie", + tokenScopes: []string{"read:private"}, + combinedOrScopes: [][]string{{"read:employee", "read:private"}, {"read:all"}}, + want: []string{"read:employee", "read:private"}, + }, + { + name: "AND group: empty token picks shorter group (read:all needs 1 vs 2)", + tokenScopes: []string{}, + combinedOrScopes: [][]string{{"read:employee", "read:private"}, {"read:all"}}, + want: []string{"read:all"}, + }, + + // --- Cross-product: multiple scoped fields --- + // 3 scoped fields with cross-product yielding 6 groups (see plan Operation 5) + { + name: "cross-product: token has read:all picks simplest group", + tokenScopes: []string{"read:all"}, + combinedOrScopes: [][]string{ + {"read:fact", "read:scalar", "read:miscellaneous"}, + {"read:fact", "read:scalar", "read:all", "read:miscellaneous"}, + {"read:fact", "read:all", "read:scalar", "read:miscellaneous"}, + {"read:fact", "read:all", "read:miscellaneous"}, + {"read:all", "read:scalar", "read:miscellaneous"}, + {"read:all", "read:miscellaneous"}, + }, + want: []string{"read:all", "read:miscellaneous"}, // missing only 1 + }, + { + name: "cross-product: partial match narrows to best group", + tokenScopes: []string{"read:fact", "read:scalar"}, + combinedOrScopes: [][]string{ + {"read:fact", "read:scalar", "read:miscellaneous"}, + {"read:fact", "read:scalar", "read:all", "read:miscellaneous"}, + {"read:fact", "read:all", "read:scalar", "read:miscellaneous"}, + {"read:fact", "read:all", "read:miscellaneous"}, + {"read:all", "read:scalar", "read:miscellaneous"}, + {"read:all", "read:miscellaneous"}, + }, + want: []string{"read:fact", "read:scalar", "read:miscellaneous"}, // missing only "read:miscellaneous" + }, + { + name: "cross-product: empty token picks group with fewest total scopes", + tokenScopes: []string{}, + combinedOrScopes: [][]string{ + {"read:fact", "read:scalar", "read:miscellaneous"}, + {"read:fact", "read:scalar", "read:all", "read:miscellaneous"}, + {"read:fact", "read:all", "read:scalar", "read:miscellaneous"}, + {"read:fact", "read:all", "read:miscellaneous"}, + {"read:all", "read:scalar", "read:miscellaneous"}, + {"read:all", "read:miscellaneous"}, + }, + want: []string{"read:all", "read:miscellaneous"}, // fewest total: 2 + }, + + // --- Cross-subgraph aggregation --- + // Products + Employees subgraph scoped fields, cross-product yields 4 groups + { + name: "cross-subgraph: token has read:all passes single-scope group", + tokenScopes: []string{"read:all"}, + combinedOrScopes: [][]string{ + {"read:fact", "read:employee", "read:private"}, + {"read:fact", "read:all"}, + {"read:all", "read:employee", "read:private"}, + {"read:all"}, + }, + want: nil, + }, + { + name: "cross-subgraph: partial match picks closest group", + tokenScopes: []string{"read:fact"}, + combinedOrScopes: [][]string{ + {"read:fact", "read:employee", "read:private"}, + {"read:fact", "read:all"}, + {"read:all", "read:employee", "read:private"}, + {"read:all"}, + }, + want: []string{"read:fact", "read:all"}, // missing 1, tied with group 4, first tie wins + }, + { + name: "cross-subgraph: unrelated partial match picks smallest group", + tokenScopes: []string{"read:employee"}, + combinedOrScopes: [][]string{ + {"read:fact", "read:employee", "read:private"}, + {"read:fact", "read:all"}, + {"read:all", "read:employee", "read:private"}, + {"read:all"}, + }, + want: []string{"read:all"}, // missing 1, clear winner + }, + { + name: "cross-subgraph: empty token picks smallest group", + tokenScopes: []string{}, + combinedOrScopes: [][]string{ + {"read:fact", "read:employee", "read:private"}, + {"read:fact", "read:all"}, + {"read:all", "read:employee", "read:private"}, + {"read:all"}, + }, + want: []string{"read:all"}, // fewest missing: 1 + }, + + // --- Edge cases --- + { + name: "nil combined scopes returns nil", + tokenScopes: []string{"some:scope"}, + combinedOrScopes: nil, + want: nil, + }, + { + name: "empty combined scopes returns nil", + tokenScopes: []string{"some:scope"}, + combinedOrScopes: [][]string{}, + want: nil, + }, + { + name: "single AND-group not satisfied returns that group", + tokenScopes: []string{"a"}, + combinedOrScopes: [][]string{{"a", "b", "c"}}, + want: []string{"a", "b", "c"}, + }, + { + name: "single AND-group fully satisfied returns nil", + tokenScopes: []string{"a", "b", "c"}, + combinedOrScopes: [][]string{{"a", "b", "c"}}, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := BestScopeChallenge(tt.tokenScopes, tt.combinedOrScopes) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestBestScopeChallengeWithExisting(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tokenScopes []string + combinedOrScopes [][]string + includeExisting bool + want []string + }{ + { + name: "include existing: unions token scopes with best group", + tokenScopes: []string{"init", "mcp:tools:write", "a"}, + combinedOrScopes: [][]string{{"a", "b", "d"}, {"a", "c", "d"}}, + includeExisting: true, + want: []string{"init", "mcp:tools:write", "a", "b", "d"}, + }, + { + name: "exclude existing: returns only best group", + tokenScopes: []string{"init", "mcp:tools:write", "a"}, + combinedOrScopes: [][]string{{"a", "b", "d"}, {"a", "c", "d"}}, + includeExisting: false, + want: []string{"a", "b", "d"}, + }, + { + name: "include existing: passes returns nil", + tokenScopes: []string{"a", "b", "d"}, + combinedOrScopes: [][]string{{"a", "b", "d"}, {"a", "c", "d"}}, + includeExisting: true, + want: nil, + }, + { + name: "include existing: deduplicates overlapping scopes", + tokenScopes: []string{"read:employee"}, + combinedOrScopes: [][]string{{"read:employee", "read:private"}, {"read:all"}}, + includeExisting: true, + want: []string{"read:employee", "read:private"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := BestScopeChallengeWithExisting(tt.tokenScopes, tt.combinedOrScopes, tt.includeExisting) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestSatisfiesAnyGroup(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tokenScopes []string + orScopes [][]string + want bool + }{ + { + name: "satisfies first AND-group", + tokenScopes: []string{"a", "b"}, + orScopes: [][]string{{"a", "b"}, {"c", "d"}}, + want: true, + }, + { + name: "satisfies second AND-group with extra scopes", + tokenScopes: []string{"c", "d", "e"}, + orScopes: [][]string{{"a", "b"}, {"c", "d"}}, + want: true, + }, + { + name: "partial match on each group fails", + tokenScopes: []string{"a", "c"}, + orScopes: [][]string{{"a", "b"}, {"c", "d"}}, + want: false, + }, + { + name: "empty requirements always passes", + tokenScopes: []string{}, + orScopes: [][]string{}, + want: true, + }, + { + name: "nil requirements always passes", + tokenScopes: []string{}, + orScopes: nil, + want: true, + }, + { + name: "empty token with requirements fails", + tokenScopes: []string{}, + orScopes: [][]string{{"a"}}, + want: false, + }, + { + name: "token superset of AND-group passes", + tokenScopes: []string{"a", "b", "c", "d"}, + orScopes: [][]string{{"a", "b"}}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := SatisfiesAnyGroup(tt.tokenScopes, tt.orScopes) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/router/pkg/mcpserver/scope_extractor.go b/router/pkg/mcpserver/scope_extractor.go new file mode 100644 index 0000000000..c812930183 --- /dev/null +++ b/router/pkg/mcpserver/scope_extractor.go @@ -0,0 +1,157 @@ +package mcpserver + +import ( + nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" + "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" +) + +// FieldScopeRequirement represents the scope requirement for a single field. +// OrScopes is a list of AND-groups — satisfy any one group to access the field. +// e.g., [["a", "b"], ["c"]] means (a AND b) OR (c) +type FieldScopeRequirement struct { + TypeName string + FieldName string + OrScopes [][]string +} + +// ScopeExtractor walks operations and extracts per-field scope requirements +// from FieldConfigurations. +type ScopeExtractor struct { + // scopeIndex maps "TypeName.FieldName" to OR-of-AND scope groups for O(1) lookup. + scopeIndex map[string][][]string + schemaDoc *ast.Document +} + +// NewScopeExtractor creates a new ScopeExtractor. +func NewScopeExtractor(fieldConfigs []*nodev1.FieldConfiguration, schemaDoc *ast.Document) *ScopeExtractor { + index := make(map[string][][]string) + for _, fc := range fieldConfigs { + authConfig := fc.GetAuthorizationConfiguration() + if authConfig == nil { + continue + } + orScopes := authConfig.GetRequiredOrScopes() + if len(orScopes) == 0 { + continue + } + groups := make([][]string, len(orScopes)) + for i, s := range orScopes { + groups[i] = s.GetRequiredAndScopes() + } + index[fc.GetTypeName()+"."+fc.GetFieldName()] = groups + } + return &ScopeExtractor{ + scopeIndex: index, + schemaDoc: schemaDoc, + } +} + +// ExtractScopesForOperation walks the operation's selection set and returns +// per-field scope requirements for fields that have @requiresScopes. +func (e *ScopeExtractor) ExtractScopesForOperation(operation *ast.Document) []FieldScopeRequirement { + walker := astvisitor.NewWalker(48) + + v := &scopeFieldVisitor{ + walker: &walker, + operation: operation, + definition: e.schemaDoc, + scopeIndex: e.scopeIndex, + } + + walker.RegisterEnterFieldVisitor(v) + + report := &operationreport.Report{} + walker.Walk(operation, e.schemaDoc, report) + + return v.results +} + +// ComputeCombinedScopes computes the Cartesian product of OR-groups across fields, +// deduplicating scopes within each combined AND-group. +func (e *ScopeExtractor) ComputeCombinedScopes(fieldReqs []FieldScopeRequirement) [][]string { + if len(fieldReqs) == 0 { + return nil + } + + // Start with the first field's OR-groups + result := fieldReqs[0].OrScopes + + // Iteratively cross-product with each subsequent field's OR-groups + for i := 1; i < len(fieldReqs); i++ { + result = crossProduct(result, fieldReqs[i].OrScopes) + } + + return result +} + +// scopeFieldVisitor collects scoped field coordinates during AST walking. +type scopeFieldVisitor struct { + walker *astvisitor.Walker + operation *ast.Document + definition *ast.Document + scopeIndex map[string][][]string + results []FieldScopeRequirement + seen map[string]struct{} // dedup "TypeName.FieldName" +} + +func (v *scopeFieldVisitor) EnterField(ref int) { + typeName := v.walker.EnclosingTypeDefinition.NameString(v.definition) + fieldName := v.operation.FieldNameString(ref) + + coordinate := typeName + "." + fieldName + + // Deduplicate — a field can appear multiple times in a selection set + if v.seen == nil { + v.seen = make(map[string]struct{}) + } + if _, ok := v.seen[coordinate]; ok { + return + } + + orScopes, ok := v.scopeIndex[coordinate] + if !ok { + return + } + + v.seen[coordinate] = struct{}{} + v.results = append(v.results, FieldScopeRequirement{ + TypeName: typeName, + FieldName: fieldName, + OrScopes: orScopes, + }) +} + +// crossProduct computes the Cartesian product of two sets of OR-groups, +// merging AND-scopes within each combination and deduplicating. +func crossProduct(a, b [][]string) [][]string { + result := make([][]string, 0, len(a)*len(b)) + for _, groupA := range a { + for _, groupB := range b { + merged := mergeAndDedup(groupA, groupB) + result = append(result, merged) + } + } + return result +} + +// mergeAndDedup merges two AND-groups into one, preserving order and removing duplicates. +func mergeAndDedup(a, b []string) []string { + seen := make(map[string]struct{}, len(a)+len(b)) + result := make([]string, 0, len(a)+len(b)) + for _, s := range a { + if _, ok := seen[s]; !ok { + seen[s] = struct{}{} + result = append(result, s) + } + } + for _, s := range b { + if _, ok := seen[s]; !ok { + seen[s] = struct{}{} + result = append(result, s) + } + } + return result +} diff --git a/router/pkg/mcpserver/scope_extractor_test.go b/router/pkg/mcpserver/scope_extractor_test.go new file mode 100644 index 0000000000..7cec93eeec --- /dev/null +++ b/router/pkg/mcpserver/scope_extractor_test.go @@ -0,0 +1,459 @@ +package mcpserver + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" + "github.com/wundergraph/graphql-go-tools/v2/pkg/asttransform" +) + +// testFieldConfigs returns field configurations matching the demo subgraphs' +// @requiresScopes directives after composition (from config.json). +func testFieldConfigs() []*nodev1.FieldConfiguration { + return []*nodev1.FieldConfiguration{ + { + TypeName: "Query", + FieldName: "topSecretFederationFacts", + AuthorizationConfiguration: &nodev1.AuthorizationConfiguration{ + RequiredOrScopes: []*nodev1.Scopes{ + {RequiredAndScopes: []string{"read:fact"}}, + {RequiredAndScopes: []string{"read:all"}}, + }, + }, + }, + { + TypeName: "Mutation", + FieldName: "addFact", + AuthorizationConfiguration: &nodev1.AuthorizationConfiguration{ + RequiredOrScopes: []*nodev1.Scopes{ + {RequiredAndScopes: []string{"write:fact"}}, + {RequiredAndScopes: []string{"write:all"}}, + }, + }, + }, + { + TypeName: "Employee", + FieldName: "startDate", + AuthorizationConfiguration: &nodev1.AuthorizationConfiguration{ + RequiredOrScopes: []*nodev1.Scopes{ + {RequiredAndScopes: []string{"read:employee", "read:private"}}, + {RequiredAndScopes: []string{"read:all"}}, + }, + }, + }, + { + TypeName: "TopSecretFact", + FieldName: "description", + AuthorizationConfiguration: &nodev1.AuthorizationConfiguration{ + RequiredOrScopes: []*nodev1.Scopes{ + {RequiredAndScopes: []string{"read:scalar"}}, + {RequiredAndScopes: []string{"read:all"}}, + }, + }, + }, + { + TypeName: "DirectiveFact", + FieldName: "description", + AuthorizationConfiguration: &nodev1.AuthorizationConfiguration{ + RequiredOrScopes: []*nodev1.Scopes{ + {RequiredAndScopes: []string{"read:scalar"}}, + {RequiredAndScopes: []string{"read:all"}}, + }, + }, + }, + { + TypeName: "EntityFact", + FieldName: "description", + AuthorizationConfiguration: &nodev1.AuthorizationConfiguration{ + RequiredOrScopes: []*nodev1.Scopes{ + {RequiredAndScopes: []string{"read:scalar"}}, + {RequiredAndScopes: []string{"read:all"}}, + }, + }, + }, + { + TypeName: "MiscellaneousFact", + FieldName: "description", + AuthorizationConfiguration: &nodev1.AuthorizationConfiguration{ + RequiredOrScopes: []*nodev1.Scopes{ + {RequiredAndScopes: []string{"read:scalar", "read:miscellaneous"}}, + {RequiredAndScopes: []string{"read:all", "read:miscellaneous"}}, + }, + }, + }, + // Fields with no scope requirements (included to verify they're ignored) + { + TypeName: "Query", + FieldName: "employees", + }, + { + TypeName: "Query", + FieldName: "employee", + }, + { + TypeName: "Employee", + FieldName: "id", + }, + { + TypeName: "Employee", + FieldName: "tag", + }, + } +} + +// testSchemaSDL is a minimal schema covering the demo subgraph types +// needed for selection set walking in scope extraction tests. +const testSchemaSDL = ` +type Query { + employees: [Employee!]! + employee(id: Int!): Employee + topSecretFederationFacts: [TopSecretFact!]! +} + +type Mutation { + addFact(fact: TopSecretFactInput!): TopSecretFact! +} + +input TopSecretFactInput { + title: String! + description: String +} + +type Employee { + id: Int! + details: Details! + tag: String! + updatedAt: String! + startDate: String! +} + +type Details { + forename: String! + surname: String! +} + +interface TopSecretFact { + title: String! + description: String +} + +type DirectiveFact implements TopSecretFact { + title: String! + description: String +} + +type EntityFact implements TopSecretFact { + title: String! + description: String +} + +type MiscellaneousFact implements TopSecretFact { + title: String! + description: String +} +` + +// parseTestSchema parses the test schema SDL and merges it with the base schema +// (required by the AST walker to resolve operation types like Query/Mutation). +func parseTestSchema(t *testing.T) ast.Document { + t.Helper() + doc, report := astparser.ParseGraphqlDocumentString(testSchemaSDL) + require.False(t, report.HasErrors(), "schema parse error: %s", report.Error()) + require.NoError(t, asttransform.MergeDefinitionWithBaseSchema(&doc)) + return doc +} + +func TestExtractScopesForOperation(t *testing.T) { + t.Parallel() + + fieldConfigs := testFieldConfigs() + + tests := []struct { + name string + operation string + wantFields int // expected number of scoped FieldScopeRequirements + wantNoScopes bool // expect nil/empty RequiredScopes + }{ + { + name: "no scoped fields: list employees with public info", + operation: ` + query ListEmployees { + employees { + id + details { + forename + surname + } + tag + } + }`, + wantFields: 0, + wantNoScopes: true, + }, + { + name: "single scoped root query field: facts titles only", + operation: ` + query GetTopSecretFacts { + topSecretFederationFacts { + ... on DirectiveFact { title } + ... on EntityFact { title } + ... on MiscellaneousFact { title } + } + }`, + wantFields: 1, // Query.topSecretFederationFacts + }, + { + name: "single scoped mutation field", + operation: ` + mutation AddFact($fact: TopSecretFactInput!) { + addFact(fact: $fact) { + ... on DirectiveFact { title } + ... on EntityFact { title } + ... on MiscellaneousFact { title } + } + }`, + wantFields: 1, // Mutation.addFact + }, + { + name: "single scoped entity field with AND group", + operation: ` + query GetEmployeeStartDate($id: Int!) { + employee(id: $id) { + id + details { forename surname } + startDate + } + }`, + wantFields: 1, // Employee.startDate + }, + { + name: "multiple scoped fields via inline fragments on different types", + operation: ` + query GetTopSecretFactsWithDescriptions { + topSecretFederationFacts { + ... on DirectiveFact { + title + description + } + ... on MiscellaneousFact { + title + description + } + } + }`, + wantFields: 3, // Query.topSecretFederationFacts, DirectiveFact.description, MiscellaneousFact.description + }, + { + name: "cross-subgraph scoped fields from products and employees", + operation: ` + query GetFactsAndEmployeeStartDate($id: Int!) { + topSecretFederationFacts { + ... on DirectiveFact { title } + } + employee(id: $id) { + id + startDate + } + }`, + wantFields: 2, // Query.topSecretFederationFacts, Employee.startDate + }, + { + name: "no scoped fields despite touching scoped type (startDate excluded)", + operation: ` + query GetEmployeeBasicInfo($id: Int!) { + employee(id: $id) { + id + tag + updatedAt + } + }`, + wantFields: 0, + wantNoScopes: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + schemaDoc := parseTestSchema(t) + + opDoc, opReport := astparser.ParseGraphqlDocumentString(tt.operation) + require.False(t, opReport.HasErrors(), "operation parse error: %s", opReport.Error()) + + extractor := NewScopeExtractor(fieldConfigs, &schemaDoc) + fieldReqs := extractor.ExtractScopesForOperation(&opDoc) + + if tt.wantNoScopes { + assert.Empty(t, fieldReqs, "expected no scoped fields") + } else { + assert.Len(t, fieldReqs, tt.wantFields, "unexpected number of scoped fields") + } + }) + } +} + +func TestExtractScopesForOperation_FieldDetails(t *testing.T) { + t.Parallel() + + fieldConfigs := testFieldConfigs() + + schemaDoc := parseTestSchema(t) + + extractor := NewScopeExtractor(fieldConfigs, &schemaDoc) + + t.Run("root query field returns correct OR-of-AND scopes", func(t *testing.T) { + t.Parallel() + opDoc, report := astparser.ParseGraphqlDocumentString(` + query GetTopSecretFacts { + topSecretFederationFacts { + ... on DirectiveFact { title } + } + }`) + require.False(t, report.HasErrors()) + + fieldReqs := extractor.ExtractScopesForOperation(&opDoc) + require.Len(t, fieldReqs, 1) + assert.Equal(t, "Query", fieldReqs[0].TypeName) + assert.Equal(t, "topSecretFederationFacts", fieldReqs[0].FieldName) + assert.Equal(t, [][]string{{"read:fact"}, {"read:all"}}, fieldReqs[0].OrScopes) + }) + + t.Run("entity field returns AND scopes with OR alternative", func(t *testing.T) { + t.Parallel() + opDoc, report := astparser.ParseGraphqlDocumentString(` + query GetEmployeeStartDate($id: Int!) { + employee(id: $id) { + startDate + } + }`) + require.False(t, report.HasErrors()) + + fieldReqs := extractor.ExtractScopesForOperation(&opDoc) + require.Len(t, fieldReqs, 1) + assert.Equal(t, "Employee", fieldReqs[0].TypeName) + assert.Equal(t, "startDate", fieldReqs[0].FieldName) + assert.Equal(t, [][]string{{"read:employee", "read:private"}, {"read:all"}}, fieldReqs[0].OrScopes) + }) + + t.Run("mutation field returns correct scopes", func(t *testing.T) { + t.Parallel() + opDoc, report := astparser.ParseGraphqlDocumentString(` + mutation AddFact($fact: TopSecretFactInput!) { + addFact(fact: $fact) { + ... on DirectiveFact { title } + } + }`) + require.False(t, report.HasErrors()) + + fieldReqs := extractor.ExtractScopesForOperation(&opDoc) + require.Len(t, fieldReqs, 1) + assert.Equal(t, "Mutation", fieldReqs[0].TypeName) + assert.Equal(t, "addFact", fieldReqs[0].FieldName) + assert.Equal(t, [][]string{{"write:fact"}, {"write:all"}}, fieldReqs[0].OrScopes) + }) +} + +func TestComputeCombinedScopes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fieldReqs []FieldScopeRequirement + want [][]string + }{ + { + name: "no field requirements returns nil", + fieldReqs: nil, + want: nil, + }, + { + name: "single field passes through directly", + fieldReqs: []FieldScopeRequirement{ + { + TypeName: "Query", + FieldName: "topSecretFederationFacts", + OrScopes: [][]string{{"read:fact"}, {"read:all"}}, + }, + }, + want: [][]string{{"read:fact"}, {"read:all"}}, + }, + { + name: "two fields: cross-product with dedup", + fieldReqs: []FieldScopeRequirement{ + { + TypeName: "Query", + FieldName: "topSecretFederationFacts", + OrScopes: [][]string{{"read:fact"}, {"read:all"}}, + }, + { + TypeName: "Employee", + FieldName: "startDate", + OrScopes: [][]string{{"read:employee", "read:private"}, {"read:all"}}, + }, + }, + want: [][]string{ + {"read:fact", "read:employee", "read:private"}, + {"read:fact", "read:all"}, + {"read:all", "read:employee", "read:private"}, + {"read:all"}, // dedup: "read:all" + "read:all" → "read:all" + }, + }, + { + name: "three fields: full cross-product with dedup", + fieldReqs: []FieldScopeRequirement{ + { + TypeName: "Query", + FieldName: "topSecretFederationFacts", + OrScopes: [][]string{{"read:fact"}, {"read:all"}}, + }, + { + TypeName: "DirectiveFact", + FieldName: "description", + OrScopes: [][]string{{"read:scalar"}, {"read:all"}}, + }, + { + TypeName: "MiscellaneousFact", + FieldName: "description", + OrScopes: [][]string{{"read:scalar", "read:miscellaneous"}, {"read:all", "read:miscellaneous"}}, + }, + }, + want: [][]string{ + // read:fact × read:scalar × (read:scalar, read:miscellaneous) → dedup read:scalar + {"read:fact", "read:scalar", "read:miscellaneous"}, + // read:fact × read:scalar × (read:all, read:miscellaneous) + {"read:fact", "read:scalar", "read:all", "read:miscellaneous"}, + // read:fact × read:all × (read:scalar, read:miscellaneous) + {"read:fact", "read:all", "read:scalar", "read:miscellaneous"}, + // read:fact × read:all × (read:all, read:miscellaneous) → dedup read:all + {"read:fact", "read:all", "read:miscellaneous"}, + // read:all × read:scalar × (read:scalar, read:miscellaneous) → dedup read:scalar + {"read:all", "read:scalar", "read:miscellaneous"}, + // read:all × read:scalar × (read:all, read:miscellaneous) → dedup read:all + {"read:all", "read:scalar", "read:miscellaneous"}, + // read:all × read:all × (read:scalar, read:miscellaneous) → dedup read:all + {"read:all", "read:scalar", "read:miscellaneous"}, + // read:all × read:all × (read:all, read:miscellaneous) → dedup all + {"read:all", "read:miscellaneous"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + schemaDoc := parseTestSchema(t) + + extractor := NewScopeExtractor(testFieldConfigs(), &schemaDoc) + got := extractor.ComputeCombinedScopes(tt.fieldReqs) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/router/pkg/mcpserver/server.go b/router/pkg/mcpserver/server.go index 414b19683d..fb0b770132 100644 --- a/router/pkg/mcpserver/server.go +++ b/router/pkg/mcpserver/server.go @@ -14,15 +14,19 @@ import ( "github.com/hashicorp/go-retryablehttp" "github.com/iancoleman/strcase" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/santhosh-tekuri/jsonschema/v6" + "go.uber.org/zap" + + nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" "github.com/wundergraph/cosmo/router/internal/headers" + "github.com/wundergraph/cosmo/router/pkg/authentication" + "github.com/wundergraph/cosmo/router/pkg/config" "github.com/wundergraph/cosmo/router/pkg/cors" "github.com/wundergraph/cosmo/router/pkg/schemaloader" + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/astprinter" - "go.uber.org/zap" ) // reservedToolNames contains tool names that are internally registered by the MCP server @@ -83,11 +87,15 @@ type Options struct { Stateless bool // CorsConfig is the CORS configuration for the MCP server CorsConfig cors.Config + // OAuthConfig is the OAuth/JWKS configuration for authentication + OAuthConfig *config.MCPOAuthConfiguration + // ServerBaseURL is the base URL of this MCP server (for resource metadata) + ServerBaseURL string } // GraphQLSchemaServer represents an MCP server that works with GraphQL schemas and operations type GraphQLSchemaServer struct { - server *server.MCPServer + server *mcp.Server graphName string operationsDir string listenAddr string @@ -95,7 +103,7 @@ type GraphQLSchemaServer struct { httpClient *http.Client requestTimeout time.Duration routerGraphQLEndpoint string - httpServer *server.StreamableHTTPServer + httpServer *http.Server excludeMutations bool enableArbitraryOperations bool exposeSchema bool @@ -105,6 +113,12 @@ type GraphQLSchemaServer struct { schemaCompiler *SchemaCompiler registeredTools []string corsConfig cors.Config + ctx context.Context + cancel context.CancelFunc + oauthConfig *config.MCPOAuthConfiguration + serverBaseURL string + authMiddleware *MCPAuthMiddleware + scopeExtractor *ScopeExtractor } type graphqlRequest struct { @@ -150,6 +164,7 @@ type GraphQLOperationInfoResponse struct { Query string `json:"query"` LLMGuidance LLMGuidance `json:"llmGuidance"` Endpoint string `json:"endpoint"` + RequiredScopes [][]string `json:"requiredScopes,omitempty"` } // GraphQLOperationInfoInput defines the input structure for the graphql_operation_info tool. @@ -177,7 +192,6 @@ type GraphQLResponse struct { // NewGraphQLSchemaServer creates a new GraphQL schema server func NewGraphQLSchemaServer(routerGraphQLEndpoint string, opts ...func(*Options)) (*GraphQLSchemaServer, error) { - if routerGraphQLEndpoint == "" { return nil, fmt.Errorf("routerGraphQLEndpoint cannot be empty") } @@ -203,14 +217,98 @@ func NewGraphQLSchemaServer(routerGraphQLEndpoint string, opts ...func(*Options) opt(options) } - // Create the MCP server - mcpServer := server.NewMCPServer( - "wundergraph-cosmo-"+strcase.ToKebab(options.GraphName), - "0.0.1", - // Prompt, Resources aren't supported yet in any of the popular platforms - server.WithToolCapabilities(true), - server.WithPaginationLimit(100), - server.WithRecovery(), + // Create a cancellable context for managing the server lifecycle + ctx, cancel := context.WithCancel(context.Background()) + + // Add authentication middleware if OAuth is configured + var authMiddleware *MCPAuthMiddleware + if options.OAuthConfig != nil && options.OAuthConfig.Enabled { + if len(options.OAuthConfig.JWKS) == 0 { + cancel() + return nil, fmt.Errorf("MCP OAuth is enabled but no JWKS providers are configured; this would start an unprotected endpoint") + } + if options.ServerBaseURL == "" { + cancel() + return nil, fmt.Errorf("MCP OAuth is enabled but server base_url is not configured; it is required for OAuth 2.0 Protected Resource Metadata discovery (RFC 9728)") + } + // Convert config.JWKSConfiguration to authentication.JWKSConfig + authConfigs := make([]authentication.JWKSConfig, 0, len(options.OAuthConfig.JWKS)) + for _, jwks := range options.OAuthConfig.JWKS { + authConfigs = append(authConfigs, authentication.JWKSConfig{ + URL: jwks.URL, + RefreshInterval: jwks.RefreshInterval, + AllowedAlgorithms: jwks.Algorithms, + Secret: jwks.Secret, + Algorithm: jwks.Algorithm, + KeyId: jwks.KeyId, + Audiences: jwks.Audiences, + RefreshUnknownKID: authentication.RefreshUnknownKIDConfig{ + Enabled: jwks.RefreshUnknownKID.Enabled, + MaxWait: jwks.RefreshUnknownKID.MaxWait, + Interval: jwks.RefreshUnknownKID.Interval, + Burst: jwks.RefreshUnknownKID.Burst, + }, + }) + } + + // Create token decoder using the managed context for proper lifecycle management + tokenDecoder, err := authentication.NewJwksTokenDecoder( + ctx, + options.Logger, + authConfigs, + ) + if err != nil { + cancel() // Clean up the context if initialization fails + return nil, fmt.Errorf("failed to create token decoder: %w", err) + } + + // Build resource metadata URL for WWW-Authenticate header + resourceMetadataURL := "" + if options.ServerBaseURL != "" { + resourceMetadataURL = fmt.Sprintf("%s/.well-known/oauth-protected-resource/mcp", options.ServerBaseURL) + } + + // Create authentication middleware with scope configuration + // The middleware checks scopes at three levels: + // - initialize: scopes required for all HTTP requests + // - tools_list: scopes required for tools/list method + // - tools_call: scopes required for tools/call method (any tool) + scopeConfig := MCPScopeConfig{ + Initialize: options.OAuthConfig.Scopes.Initialize, + ToolsList: options.OAuthConfig.Scopes.ToolsList, + ToolsCall: options.OAuthConfig.Scopes.ToolsCall, + ExecuteGraphQL: options.OAuthConfig.Scopes.ExecuteGraphQL, + GetOperationInfo: options.OAuthConfig.Scopes.GetOperationInfo, + GetSchema: options.OAuthConfig.Scopes.GetSchema, + } + authMiddleware, err = NewMCPAuthMiddleware(tokenDecoder, true, resourceMetadataURL, scopeConfig, options.OAuthConfig.ScopeChallengeIncludeTokenScopes) + if err != nil { + cancel() // Clean up the context if initialization fails + return nil, fmt.Errorf("failed to create auth middleware: %w", err) + } + + // Store auth middleware for HTTP-level protection + // Note: We don't use tool middleware here because per MCP spec, + // ALL HTTP requests must be authenticated, not just tool calls + options.Logger.Info("MCP OAuth authentication enabled", + zap.Int("jwks_providers", len(options.OAuthConfig.JWKS)), + zap.String("authorization_server", options.OAuthConfig.AuthorizationServerURL)) + } + + // Create the MCP server with all options + mcpServer := mcp.NewServer( + &mcp.Implementation{ + Name: "wundergraph-cosmo-" + strcase.ToKebab(options.GraphName), + Version: "0.0.1", + }, + &mcp.ServerOptions{ + PageSize: 100, + // Override default capabilities to disable the "logging" capability + // that the SDK advertises by default (for historical reasons). + // We don't implement logging/setLevel, so advertising it causes + // clients like MCP Inspector to call it and fail. + Capabilities: &mcp.ServerCapabilities{}, + }, ) retryClient := retryablehttp.NewClient() @@ -233,6 +331,11 @@ func NewGraphQLSchemaServer(routerGraphQLEndpoint string, opts ...func(*Options) omitToolNamePrefix: options.OmitToolNamePrefix, stateless: options.Stateless, corsConfig: options.CorsConfig, + ctx: ctx, + cancel: cancel, + oauthConfig: options.OAuthConfig, + serverBaseURL: options.ServerBaseURL, + authMiddleware: authMiddleware, } return gs, nil @@ -311,6 +414,7 @@ func WithCORS(corsCfg cors.Config) func(*Options) { corsCfg.AllowOrigins = []string{"*"} corsCfg.AllowMethods = []string{"GET", "PUT", "POST", "DELETE", "OPTIONS"} corsCfg.AllowHeaders = append(corsCfg.AllowHeaders, "Content-Type", "Accept", "Authorization", "Last-Event-ID", "Mcp-Protocol-Version", "Mcp-Session-Id") + corsCfg.ExposeHeaders = append(corsCfg.ExposeHeaders, "Mcp-Session-Id", "WWW-Authenticate") if corsCfg.MaxAge <= 0 { corsCfg.MaxAge = 24 * time.Hour } @@ -318,8 +422,22 @@ func WithCORS(corsCfg cors.Config) func(*Options) { } } -// Serve starts the server with the configured options and returns a streamable HTTP server. -func (s *GraphQLSchemaServer) Serve() (*server.StreamableHTTPServer, error) { +// WithOAuth sets the OAuth configuration +func WithOAuth(oauthCfg *config.MCPOAuthConfiguration) func(*Options) { + return func(o *Options) { + o.OAuthConfig = oauthCfg + } +} + +// WithServerBaseURL sets the server base URL for OAuth discovery +func WithServerBaseURL(baseURL string) func(*Options) { + return func(o *Options) { + o.ServerBaseURL = baseURL + } +} + +// Serve starts the server with the configured options and returns the HTTP server. +func (s *GraphQLSchemaServer) Serve() (*http.Server, error) { // Create custom HTTP server httpServer := &http.Server{ Addr: s.listenAddr, @@ -328,22 +446,44 @@ func (s *GraphQLSchemaServer) Serve() (*server.StreamableHTTPServer, error) { IdleTimeout: 60 * time.Second, } - streamableHTTPServer := server.NewStreamableHTTPServer(s.server, - server.WithStreamableHTTPServer(httpServer), - server.WithLogger(NewZapAdapter(s.logger.With(zap.String("component", "mcp-server")))), - server.WithStateLess(s.stateless), - server.WithHTTPContextFunc(requestHeadersFromRequest), - server.WithHeartbeatInterval(10*time.Second), + // Create MCP streamable HTTP handler + // The getServer function returns our MCP server instance for each request + streamableHTTPHandler := mcp.NewStreamableHTTPHandler( + func(req *http.Request) *mcp.Server { + // Add request headers to context for tool handlers + return s.server + }, + nil, // Use default options ) middleware := cors.New(s.corsConfig) mux := http.NewServeMux() - // No OAuth protection - original behavior - mux.Handle("/mcp", middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - streamableHTTPServer.ServeHTTP(w, r) - }))) + // OAuth 2.0 Protected Resource Metadata endpoint (RFC 9728 Section 3.1) + // This endpoint is required for MCP clients to discover the authorization server. + // This endpoint is NOT protected by authentication (it's public discovery). + // + // Per RFC 9728, when a resource is served at a path other than /, the well-known + // URI must include the path suffix: /.well-known/oauth-protected-resource/mcp + if s.oauthConfig != nil && s.oauthConfig.Enabled && s.oauthConfig.AuthorizationServerURL != "" { + mux.Handle("/.well-known/oauth-protected-resource/mcp", middleware(http.HandlerFunc(s.handleProtectedResourceMetadata))) + s.logger.Info("OAuth 2.0 Protected Resource Metadata endpoint enabled (RFC 9728 path-aware)", + zap.String("path", "/.well-known/oauth-protected-resource/mcp"), + zap.String("authorization_server", s.oauthConfig.AuthorizationServerURL)) + } + + // MCP endpoint with HTTP-level authentication + // Per MCP spec: "authorization MUST be included in every HTTP request from client to server" + mcpHandler := http.Handler(streamableHTTPHandler) + + // Apply authentication middleware if OAuth is enabled + if s.authMiddleware != nil { + mux.Handle("/mcp", middleware(s.authMiddleware.HTTPMiddleware(mcpHandler))) + s.logger.Info("MCP endpoint protected with OAuth authentication at HTTP level") + } else { + mux.Handle("/mcp", middleware(mcpHandler)) + } // Set the handler for the custom HTTP server httpServer.Handler = mux @@ -369,12 +509,11 @@ func (s *GraphQLSchemaServer) Serve() (*server.StreamableHTTPServer, error) { } }() - return streamableHTTPServer, nil + return httpServer, nil } // Start loads operations and starts the server func (s *GraphQLSchemaServer) Start() error { - ss, err := s.Serve() if err != nil { return fmt.Errorf("failed to create HTTP server: %w", err) @@ -385,9 +524,9 @@ func (s *GraphQLSchemaServer) Start() error { return nil } -// Reload reloads the operations and schema -func (s *GraphQLSchemaServer) Reload(schema *ast.Document) error { - +// Reload reloads the operations and schema, and computes per-tool scope +// requirements from @requiresScopes directives in the field configurations. +func (s *GraphQLSchemaServer) Reload(schema *ast.Document, fieldConfigs []*nodev1.FieldConfiguration) error { if s.server == nil { return fmt.Errorf("server is not started") } @@ -401,7 +540,20 @@ func (s *GraphQLSchemaServer) Reload(schema *ast.Document) error { } } - s.server.DeleteTools(s.registeredTools...) + // Compute per-tool scope requirements from @requiresScopes directives + if len(fieldConfigs) > 0 { + s.operationsManager.ComputeToolScopes(fieldConfigs) + s.scopeExtractor = NewScopeExtractor(fieldConfigs, schema) + } else { + s.scopeExtractor = nil + } + + // Pass scope extractor to auth middleware for runtime execute_graphql scope checking + if s.authMiddleware != nil { + s.authMiddleware.SetScopeExtractor(s.scopeExtractor) + } + + s.server.RemoveTools(s.registeredTools...) s.registeredTools = nil if err := s.registerTools(); err != nil { @@ -419,6 +571,11 @@ func (s *GraphQLSchemaServer) Stop(ctx context.Context) error { s.logger.Debug("shutting down MCP server") + // Cancel the server's context to stop background operations (e.g., JWKS key refresh) + if s.cancel != nil { + s.cancel() + } + // Create a shutdown context with timeout shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() @@ -432,70 +589,65 @@ func (s *GraphQLSchemaServer) Stop(ctx context.Context) error { // registerTools registers all tools for the MCP server func (s *GraphQLSchemaServer) registerTools() error { - // Only register the schema tool if exposeSchema is enabled if s.exposeSchema { - s.server.AddTool( - mcp.NewTool( - "get_schema", - mcp.WithDescription("Provides the full GraphQL schema of the API."), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: "Get GraphQL Schema", - ReadOnlyHint: mcp.ToBoolPtr(true), - }), - ), - s.handleGetGraphQLSchema(), - ) + // Create a schema with empty properties since get_schema takes no input + getSchemaInputSchema := map[string]any{ + "type": "object", + "properties": map[string]any{}, + } + + tool := &mcp.Tool{ + Name: "get_schema", + Description: "Provides the full GraphQL schema of the API.", + InputSchema: getSchemaInputSchema, + Annotations: &mcp.ToolAnnotations{ + Title: "Get GraphQL Schema", + ReadOnlyHint: true, + }, + } + s.server.AddTool(tool, s.handleGetGraphQLSchema()) s.registeredTools = append(s.registeredTools, "get_schema") } // Only register the execute_graphql tool if enableArbitraryOperations is enabled if s.enableArbitraryOperations { // Add a tool to execute arbitrary GraphQL queries - executeGraphQLSchema := []byte(`{ - "type": "object", + executeGraphQLSchema := map[string]any{ + "type": "object", "description": "The query and variables to execute.", - "properties": { - "query": { - "type": "string", - "description": "The GraphQL query or mutation string to execute." + "properties": map[string]any{ + "query": map[string]any{ + "type": "string", + "description": "The GraphQL query or mutation string to execute.", }, - "variables": { - "type": "object", + "variables": map[string]any{ + "type": "object", "additionalProperties": true, - "description": "The variables to pass to the GraphQL query as a JSON object." - } + "description": "The variables to pass to the GraphQL query as a JSON object.", + }, }, "additionalProperties": false, - "required": ["query"] - }`) - - // Validate the schema before using it - if err := s.schemaCompiler.ValidateJSONSchema(executeGraphQLSchema); err != nil { - return fmt.Errorf("invalid schema for execute_graphql tool: %w", err) + "required": []string{"query"}, } - tool := mcp.NewToolWithRawSchema( - "execute_graphql", - "Executes a GraphQL query or mutation.", - executeGraphQLSchema, - ) - - tool.Annotations = mcp.ToolAnnotation{ - Title: "Execute GraphQL Query", - DestructiveHint: mcp.ToBoolPtr(true), - IdempotentHint: mcp.ToBoolPtr(false), - OpenWorldHint: mcp.ToBoolPtr(true), + destructiveHint := true + openWorldHint := true + tool := &mcp.Tool{ + Name: "execute_graphql", + Description: "Executes a GraphQL query or mutation.", + InputSchema: executeGraphQLSchema, + Annotations: &mcp.ToolAnnotations{ + Title: "Execute GraphQL Query", + DestructiveHint: &destructiveHint, + IdempotentHint: false, + OpenWorldHint: &openWorldHint, + }, } - s.server.AddTool( - tool, - s.handleExecuteGraphQL(), - ) - + s.server.AddTool(tool, s.handleExecuteGraphQL()) s.registeredTools = append(s.registeredTools, "execute_graphql") - } // Get operations filtered by the excludeMutations setting @@ -503,6 +655,9 @@ func (s *GraphQLSchemaServer) registerTools() error { graphqlOperationNames := make([]string, 0, len(operations)) + // Build per-tool scope map for the auth middleware + toolScopes := make(map[string][][]string) + for _, op := range operations { var compiledSchema *jsonschema.Schema var err error @@ -556,43 +711,72 @@ func (s *GraphQLSchemaServer) registerTools() error { ) continue } - tool := mcp.NewToolWithRawSchema( - toolName, - toolDescription, - op.JSONSchema, - ) + // Parse JSON schema into map for the official SDK + var inputSchema any + if len(op.JSONSchema) > 0 { + if err := json.Unmarshal(op.JSONSchema, &inputSchema); err != nil { + s.logger.Error("failed to parse JSON schema for operation", + zap.String("operation", op.Name), + zap.Error(err)) + continue + } + } else { + inputSchema = map[string]any{"type": "object", "properties": map[string]any{}} + } - tool.Annotations = mcp.ToolAnnotation{ - IdempotentHint: mcp.ToBoolPtr(op.OperationType != "mutation"), - Title: fmt.Sprintf("Execute operation %s", op.Name), - ReadOnlyHint: mcp.ToBoolPtr(op.OperationType == "query"), - OpenWorldHint: mcp.ToBoolPtr(true), + idempotent := op.OperationType != "mutation" + openWorld := true + tool := &mcp.Tool{ + Name: toolName, + Description: toolDescription, + InputSchema: inputSchema, + Annotations: &mcp.ToolAnnotations{ + IdempotentHint: op.OperationType != "mutation", + Title: fmt.Sprintf("Execute operation %s", op.Name), + ReadOnlyHint: op.OperationType == "query", + OpenWorldHint: &openWorld, + }, } - s.server.AddTool( - tool, - s.handleOperation(handler), - ) + // IdempotentHint uses the plain bool value, but keep it for later if needed + _ = idempotent + + s.server.AddTool(tool, s.handleOperation(handler)) s.registeredTools = append(s.registeredTools, toolName) + + // Record per-tool scope requirements for auth middleware enforcement + if len(op.RequiredScopes) > 0 { + toolScopes[toolName] = op.RequiredScopes + } } - s.server.AddTool( - mcp.NewTool( - "get_operation_info", - mcp.WithDescription("Provides instructions on how to execute the GraphQL operation via HTTP and how to integrate it into your application."), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: "Get GraphQL Operation Info", - ReadOnlyHint: mcp.ToBoolPtr(true), - }), - mcp.WithString("operationName", - mcp.Required(), - mcp.Description("The exact name of the GraphQL operation to retrieve information for."), - mcp.Enum(graphqlOperationNames...), - ), - ), - s.handleGraphQLOperationInfo(), - ) + // Update auth middleware with per-tool scopes (thread-safe) + if s.authMiddleware != nil { + s.authMiddleware.SetToolScopes(toolScopes) + } + + getOperationInfoTool := &mcp.Tool{ + Name: "get_operation_info", + Description: "Provides instructions on how to execute the GraphQL operation via HTTP and how to integrate it into your application.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "operationName": map[string]any{ + "type": "string", + "description": "The exact name of the GraphQL operation to retrieve information for.", + "enum": graphqlOperationNames, + }, + }, + "required": []string{"operationName"}, + }, + Annotations: &mcp.ToolAnnotations{ + Title: "Get GraphQL Operation Info", + ReadOnlyHint: true, + }, + } + + s.server.AddTool(getOperationInfoTool, s.handleGraphQLOperationInfo()) s.registeredTools = append(s.registeredTools, "get_operation_info") @@ -600,18 +784,25 @@ func (s *GraphQLSchemaServer) registerTools() error { } // handleOperation handles a specific operation -func (s *GraphQLSchemaServer) handleOperation(handler *operationHandler) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - - jsonBytes, err := json.Marshal(request.GetArguments()) - if err != nil { - return nil, fmt.Errorf("failed to marshal arguments: %w", err) +func (s *GraphQLSchemaServer) handleOperation(handler *operationHandler) func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Log authenticated user if OAuth is enabled + if claims, ok := GetClaimsFromContext(ctx); ok { + s.logger.Debug("operation called by authenticated user", + zap.String("sub", getClaimString(claims, "sub")), + zap.String("email", getClaimString(claims, "email")), + zap.String("operation", handler.operation.Name)) } + jsonBytes := request.Params.Arguments + // Validate the JSON input against the pre-compiled schema derived from the operation input type if handler.compiledSchema != nil { if err := s.schemaCompiler.ValidateInput(jsonBytes, handler.compiledSchema); err != nil { - return mcp.NewToolResultErrorFromErr("Input validation Error", err), nil + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("Input validation error: %v", err)}}, + IsError: true, + }, nil } } @@ -621,13 +812,10 @@ func (s *GraphQLSchemaServer) handleOperation(handler *operationHandler) func(ct } // handleGraphQLOperationInfo returns a handler function that provides detailed info for a specific operation. -func (s *GraphQLSchemaServer) handleGraphQLOperationInfo() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (s *GraphQLSchemaServer) handleGraphQLOperationInfo() func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { var input GraphQLOperationInfoInput - inputBytes, err := json.Marshal(request.GetArguments()) - if err != nil { - return nil, fmt.Errorf("failed to marshal input arguments: %w", err) - } + inputBytes := request.Params.Arguments if err := json.Unmarshal(inputBytes, &input); err != nil { return nil, fmt.Errorf("failed to unmarshal input arguments: %w. Ensure you provide {\"operationName\": \"\"}", err) } @@ -683,17 +871,30 @@ Usage Instructions: } requestFormat += "```" + // Scope requirements section + var scopeInfo string + if len(targetOp.RequiredScopes) > 0 { + scopeInfo = "\nRequired Scopes (OR-of-AND):\n" + for i, andGroup := range targetOp.RequiredScopes { + if i > 0 { + scopeInfo += " OR\n" + } + scopeInfo += fmt.Sprintf(" - %s\n", strings.Join(andGroup, " AND ")) + } + } + // Important notes section importantNotes := ` - Important Notes: 1. Use the query string exactly as provided above 2. Do not modify or reformat the query string` // Combine all sections - response := overview + schemaInfo + queryInfo + usageInstructions + requestFormat + importantNotes + response := overview + schemaInfo + scopeInfo + queryInfo + usageInstructions + requestFormat + importantNotes - return mcp.NewToolResultText(response), nil + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: response}}, + }, nil } } @@ -764,27 +965,39 @@ func (s *GraphQLSchemaServer) executeGraphQLQuery(ctx context.Context, query str // If there are errors but no data, return only the errors if len(graphqlResponse.Data) == 0 || string(graphqlResponse.Data) == "null" { - return mcp.NewToolResultErrorFromErr("Response Error", err), nil + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("Response error: %s", errorMessage)}}, + IsError: true, + }, nil } // If we have both errors and data, include data in the error message dataString := string(graphqlResponse.Data) combinedErrorMsg := fmt.Sprintf("Response error with partial success, Error: %s, Data: %s)", errorMessage, dataString) - return mcp.NewToolResultErrorFromErr(combinedErrorMsg, err), nil + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: combinedErrorMsg}}, + IsError: true, + }, nil } - return mcp.NewToolResultText(string(body)), nil + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: string(body)}}, + }, nil } // handleExecuteGraphQL returns a handler function that executes arbitrary GraphQL queries -func (s *GraphQLSchemaServer) handleExecuteGraphQL() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // Parse the JSON input - jsonBytes, err := json.Marshal(request.GetArguments()) - if err != nil { - return nil, fmt.Errorf("failed to marshal arguments: %w", err) +func (s *GraphQLSchemaServer) handleExecuteGraphQL() func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Log authenticated user if OAuth is enabled + if claims, ok := GetClaimsFromContext(ctx); ok { + s.logger.Debug("arbitrary GraphQL query called by authenticated user", + zap.String("sub", getClaimString(claims, "sub")), + zap.String("email", getClaimString(claims, "email"))) } + // Parse the JSON input + jsonBytes := request.Params.Arguments + var input ExecuteGraphQLInput if err := json.Unmarshal(jsonBytes, &input); err != nil { return nil, fmt.Errorf("failed to unmarshal input arguments: %w", err) @@ -799,8 +1012,8 @@ func (s *GraphQLSchemaServer) handleExecuteGraphQL() func(ctx context.Context, r } // handleGetGraphQLSchema returns a handler function that returns the full GraphQL schema -func (s *GraphQLSchemaServer) handleGetGraphQLSchema() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (s *GraphQLSchemaServer) handleGetGraphQLSchema() func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Get the schema from the operations manager schema := s.operationsManager.GetSchema() if schema == nil { @@ -813,6 +1026,121 @@ func (s *GraphQLSchemaServer) handleGetGraphQLSchema() func(ctx context.Context, return nil, fmt.Errorf("failed to convert schema to string: %w", err) } - return mcp.NewToolResultText(schemaStr), nil + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: schemaStr}}, + }, nil + } +} + +// getClaimString safely extracts a string value from claims +func getClaimString(claims authentication.Claims, key string) string { + if val, ok := claims[key]; ok { + if str, ok := val.(string); ok { + return str + } + } + return "" +} + +// ProtectedResourceMetadata represents the OAuth 2.0 Protected Resource Metadata (RFC 9728) +type ProtectedResourceMetadata struct { + Resource string `json:"resource"` + AuthorizationServers []string `json:"authorization_servers"` + BearerMethodsSupported []string `json:"bearer_methods_supported,omitempty"` + ResourceDocumentation string `json:"resource_documentation,omitempty"` + ScopesSupported []string `json:"scopes_supported"` +} + +// handleProtectedResourceMetadata handles the OAuth 2.0 Protected Resource Metadata endpoint +// as specified in RFC 9728. This endpoint allows MCP clients to discover the authorization +// server(s) associated with this resource server. +func (s *GraphQLSchemaServer) handleProtectedResourceMetadata(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Determine the resource URL (this MCP server's base URL) + resourceURL := s.serverBaseURL + if resourceURL == "" { + // Fallback: construct from request if not configured + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + resourceURL = fmt.Sprintf("%s://%s", scheme, r.Host) + } + + // Build scopes_supported from all configured scopes (union across all levels) + // plus all scopes extracted from @requiresScopes directives on operations + scopesSet := make(map[string]bool) + // Collect all static scope lists, conditionally including built-in tool scopes + // based on whether the corresponding feature is enabled + scopeLists := [][]string{ + s.oauthConfig.Scopes.Initialize, + s.oauthConfig.Scopes.ToolsList, + s.oauthConfig.Scopes.ToolsCall, + s.oauthConfig.Scopes.GetOperationInfo, // get_operation_info is always available + } + if s.enableArbitraryOperations { + scopeLists = append(scopeLists, s.oauthConfig.Scopes.ExecuteGraphQL) + } + if s.exposeSchema { + scopeLists = append(scopeLists, s.oauthConfig.Scopes.GetSchema) + } + + for _, scopeList := range scopeLists { + for _, scope := range scopeList { + scopesSet[scope] = true + } + } + + // Include all scopes from per-tool @requiresScopes extraction + if s.operationsManager != nil { + for _, op := range s.operationsManager.GetOperations() { + for _, andGroup := range op.RequiredScopes { + for _, scope := range andGroup { + scopesSet[scope] = true + } + } + } + } + + // Convert set to sorted slice for consistent output + scopes := make([]string, 0, len(scopesSet)) + for scope := range scopesSet { + scopes = append(scopes, scope) + } + slices.Sort(scopes) + if len(scopes) == 0 { + scopes = []string{} // Ensure non-nil for JSON encoding + } + + metadata := ProtectedResourceMetadata{ + Resource: resourceURL, + AuthorizationServers: []string{s.oauthConfig.AuthorizationServerURL}, + BearerMethodsSupported: []string{"header"}, + ResourceDocumentation: fmt.Sprintf("%s/mcp", resourceURL), + ScopesSupported: scopes, // Automatically derived from required scopes + } + + // Encode to buffer first so we can handle errors before writing headers + data, err := json.Marshal(metadata) + if err != nil { + s.logger.Error("failed to encode protected resource metadata", zap.Error(err)) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} + +// GetResourceMetadataURL returns the URL for the OAuth 2.0 Protected Resource Metadata endpoint +func (s *GraphQLSchemaServer) GetResourceMetadataURL() string { + if s.serverBaseURL != "" { + return fmt.Sprintf("%s/.well-known/oauth-protected-resource/mcp", s.serverBaseURL) } + return "" } diff --git a/router/pkg/schemaloader/loader.go b/router/pkg/schemaloader/loader.go index fbb0f7dcae..e82f584357 100644 --- a/router/pkg/schemaloader/loader.go +++ b/router/pkg/schemaloader/loader.go @@ -8,11 +8,12 @@ import ( "path/filepath" "strings" + "go.uber.org/zap" + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" "github.com/wundergraph/graphql-go-tools/v2/pkg/astvalidation" "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" - "go.uber.org/zap" ) // Operation represents a GraphQL operation with its AST document and schema information @@ -23,7 +24,8 @@ type Operation struct { OperationString string Description string JSONSchema json.RawMessage - OperationType string // "query", "mutation", or "subscription" + OperationType string // "query", "mutation", or "subscription" + RequiredScopes [][]string // OR-of-AND scope groups from @requiresScopes (nil = no scope check) } // OperationLoader loads GraphQL operations from files in a directory diff --git a/router/pkg/schemaloader/loader_test.go b/router/pkg/schemaloader/loader_test.go index b4573d89a5..fde3147491 100644 --- a/router/pkg/schemaloader/loader_test.go +++ b/router/pkg/schemaloader/loader_test.go @@ -7,9 +7,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/zap" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" "github.com/wundergraph/graphql-go-tools/v2/pkg/asttransform" - "go.uber.org/zap" ) // TestLoadOperationsWithDescriptions tests that the OperationLoader properly loads From 4bda41ab9ff0f06760afad5c359a63c590a38d93 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Thu, 19 Mar 2026 11:48:45 +0000 Subject: [PATCH 02/46] fix(mcp): address code review findings for OAuth auth middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix server_test.go Reload() calls to match new signature (second arg) - Sanitize error_description quotes in WWW-Authenticate headers (RFC 6750) - Skip body parsing for non-POST requests in auth middleware - Remove unused authenticateRequest method and contains wrapper - Fix ScopeChallengeMode → ScopeChallengeIncludeTokenScopes in e2e test --- router-tests/mcp_oauth_e2e_test.go | 2 +- router/pkg/mcpserver/auth_middleware.go | 53 +++++-------------------- router/pkg/mcpserver/scope_extractor.go | 2 + router/pkg/mcpserver/server_test.go | 8 ++-- 4 files changed, 18 insertions(+), 47 deletions(-) diff --git a/router-tests/mcp_oauth_e2e_test.go b/router-tests/mcp_oauth_e2e_test.go index 950f1a0c01..2125b1fe9d 100644 --- a/router-tests/mcp_oauth_e2e_test.go +++ b/router-tests/mcp_oauth_e2e_test.go @@ -176,7 +176,7 @@ func TestMCPOAuthPerToolScopes(t *testing.T) { Initialize: []string{"mcp:connect"}, ToolsCall: []string{"mcp:tools:write"}, }, - ScopeChallengeMode: "required_and_existing", + ScopeChallengeIncludeTokenScopes: true, }, }, MCPAuthToken: initToken, diff --git a/router/pkg/mcpserver/auth_middleware.go b/router/pkg/mcpserver/auth_middleware.go index 4c0d4a65a8..dea66baaa1 100644 --- a/router/pkg/mcpserver/auth_middleware.go +++ b/router/pkg/mcpserver/auth_middleware.go @@ -135,34 +135,6 @@ func (m *MCPAuthMiddleware) getScopeExtractor() *ScopeExtractor { return m.scopeExtractor } -// authenticateRequest extracts and validates the JWT token using the existing -// authentication infrastructure from the router -func (m *MCPAuthMiddleware) authenticateRequest(ctx context.Context) (authentication.Claims, error) { - // Extract headers from context (passed by mcp-go HTTP transport) - headers, err := headersFromContext(ctx) - if err != nil { - return nil, fmt.Errorf("missing request headers: %w", err) - } - - // Use the existing authenticator instead of manual token parsing - // This provides better error messages and supports multiple authentication schemes - provider := &mcpAuthProvider{headers: headers} - claims, err := m.authenticator.Authenticate(ctx, provider) - if err != nil { - return nil, fmt.Errorf("authentication failed: %w", err) - } - - // If claims are empty, treat as authentication failure - if len(claims) == 0 { - return nil, fmt.Errorf("authentication failed: no valid credentials provided") - } - - // Note: Scope validation is now handled at HTTP level, not here - // This is per MCP spec: authorization must be at HTTP level - - return claims, nil -} - // HTTPMiddleware wraps HTTP handlers with authentication for ALL MCP operations // Per MCP specification: "authorization MUST be included in every HTTP request from client to server" func (m *MCPAuthMiddleware) HTTPMiddleware(next http.Handler) http.Handler { @@ -191,10 +163,9 @@ func (m *MCPAuthMiddleware) HTTPMiddleware(next http.Handler) http.Handler { } // Step 2: Parse JSON-RPC request to check method-level scopes - // Read body to extract method name (only if body exists) - // Use LimitReader to prevent memory exhaustion from oversized payloads + // Only parse body for POST requests with JSON content (SSE/GET requests have no JSON-RPC body) var body []byte - if r.Body != nil { + if r.Method == http.MethodPost && r.Body != nil { limitedReader := io.LimitReader(r.Body, maxBodyBytes+1) body, err = io.ReadAll(limitedReader) if err != nil { @@ -294,9 +265,10 @@ func (m *MCPAuthMiddleware) sendUnauthorizedResponse(w http.ResponseWriter, err authHeader += fmt.Sprintf(`, resource_metadata="%s"`, m.resourceMetadataURL) } - // Add optional error_description for debugging + // Add optional error_description for debugging (sanitize quotes per RFC 6750 quoted-string) if err != nil { - authHeader += fmt.Sprintf(`, error_description="%s"`, err.Error()) + desc := strings.ReplaceAll(err.Error(), `"`, `'`) + authHeader += fmt.Sprintf(`, error_description="%s"`, desc) } w.Header().Set("WWW-Authenticate", authHeader) @@ -345,9 +317,10 @@ func (m *MCPAuthMiddleware) sendInsufficientScopeResponse(w http.ResponseWriter, authHeader += fmt.Sprintf(`, resource_metadata="%s"`, m.resourceMetadataURL) } - // Add optional error_description for human-readable message + // Add optional error_description for human-readable message (sanitize quotes per RFC 6750 quoted-string) if err != nil { - authHeader += fmt.Sprintf(`, error_description="%s"`, err.Error()) + desc := strings.ReplaceAll(err.Error(), `"`, `'`) + authHeader += fmt.Sprintf(`, error_description="%s"`, desc) } w.Header().Set("WWW-Authenticate", authHeader) @@ -368,7 +341,8 @@ func (m *MCPAuthMiddleware) sendPerToolInsufficientScopeResponse(w http.Response authHeader += fmt.Sprintf(`, resource_metadata="%s"`, m.resourceMetadataURL) } - authHeader += fmt.Sprintf(`, error_description="insufficient scopes for tool %s"`, toolName) + sanitizedName := strings.ReplaceAll(toolName, `"`, `'`) + authHeader += fmt.Sprintf(`, error_description="insufficient scopes for tool %s"`, sanitizedName) w.Header().Set("WWW-Authenticate", authHeader) w.WriteHeader(http.StatusForbidden) @@ -421,7 +395,7 @@ func (m *MCPAuthMiddleware) validateScopesForRequest(claims authentication.Claim // Check if all required scopes are present var missingScopes []string for _, requiredScope := range requiredScopes { - if !contains(tokenScopes, requiredScope) { + if !slices.Contains(tokenScopes, requiredScope) { missingScopes = append(missingScopes, requiredScope) } } @@ -453,11 +427,6 @@ func extractScopes(claims authentication.Claims) []string { return strings.Fields(scopeStr) } -// contains checks if a slice contains a specific string -func contains(slice []string, item string) bool { - return slices.Contains(slice, item) -} - // GetClaimsFromContext retrieves authenticated user claims from context func GetClaimsFromContext(ctx context.Context) (authentication.Claims, bool) { claims, ok := ctx.Value(userClaimsContextKey).(authentication.Claims) diff --git a/router/pkg/mcpserver/scope_extractor.go b/router/pkg/mcpserver/scope_extractor.go index c812930183..70ba02d721 100644 --- a/router/pkg/mcpserver/scope_extractor.go +++ b/router/pkg/mcpserver/scope_extractor.go @@ -71,6 +71,8 @@ func (e *ScopeExtractor) ExtractScopesForOperation(operation *ast.Document) []Fi // ComputeCombinedScopes computes the Cartesian product of OR-groups across fields, // deduplicating scopes within each combined AND-group. +// The product is unbounded at runtime because the composition layer already enforces +// MAX_OR_SCOPES = 16 per field, capping the scope groups that reach the router config. func (e *ScopeExtractor) ComputeCombinedScopes(fieldReqs []FieldScopeRequirement) [][]string { if len(fieldReqs) == 0 { return nil diff --git a/router/pkg/mcpserver/server_test.go b/router/pkg/mcpserver/server_test.go index 3c18725402..9f1e4a3366 100644 --- a/router/pkg/mcpserver/server_test.go +++ b/router/pkg/mcpserver/server_test.go @@ -89,14 +89,14 @@ func TestReload_NoToolDuplication(t *testing.T) { require.NoError(t, err) // First load - err = srv.Reload(&schemaDoc) + err = srv.Reload(&schemaDoc, nil) require.NoError(t, err) firstLoadTools := make([]string, len(srv.registeredTools)) copy(firstLoadTools, srv.registeredTools) // Second load (simulates config reload) - err = srv.Reload(&schemaDoc) + err = srv.Reload(&schemaDoc, nil) require.NoError(t, err) // registeredTools should be identical after reload — no duplicates @@ -134,7 +134,7 @@ func TestReload_ReservedToolNameCollision(t *testing.T) { ) require.NoError(t, err) - err = srv.Reload(&schemaDoc) + err = srv.Reload(&schemaDoc, nil) require.NoError(t, err) // The operation "GetOperationInfo" (snake: "get_operation_info") should be skipped @@ -177,7 +177,7 @@ func TestReload_PrefixModeAvoidsReservedNameCollision(t *testing.T) { ) require.NoError(t, err) - err = srv.Reload(&schemaDoc) + err = srv.Reload(&schemaDoc, nil) require.NoError(t, err) // No collisions because the prefix disambiguates from the reserved name From e966e52c8907c8535ab89441265de1f1eeb35413 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Thu, 19 Mar 2026 12:23:51 +0000 Subject: [PATCH 03/46] fix(mcp): validate redirect_uri and remove scaffold test code - Validate redirect_uri against registered URIs in both OAuth test servers - Remove scaffold tests (TestMCPAuthorizationWithOfficialSDK) that had no real auth - Remove unused mcp_auth_harness_example.go - Keep MCPAuthClient helpers used by real OAuth e2e tests --- router-tests/cmd/oauth-server/main.go | 32 ++- router-tests/mcp_auth_e2e_test.go | 211 +------------------- router-tests/mcp_auth_harness_example.go | 242 ----------------------- router-tests/testutil/oauth_server.go | 24 +++ 4 files changed, 56 insertions(+), 453 deletions(-) delete mode 100644 router-tests/mcp_auth_harness_example.go diff --git a/router-tests/cmd/oauth-server/main.go b/router-tests/cmd/oauth-server/main.go index 38f5a5be70..21e06a1eef 100644 --- a/router-tests/cmd/oauth-server/main.go +++ b/router-tests/cmd/oauth-server/main.go @@ -120,9 +120,10 @@ type oauthHandler struct { } type client struct { - id string - secret string - scope string + id string + secret string + scope string + redirectURIs []string } type authCode struct { @@ -297,6 +298,29 @@ func (h *oauthHandler) handleAuthorize(w http.ResponseWriter, r *http.Request) { return } + // Validate that the redirect URI matches one of the client's registered redirect URIs. + h.mu.RLock() + c, ok := h.clients[clientID] + h.mu.RUnlock() + if !ok { + http.Error(w, "unknown client_id", http.StatusBadRequest) + return + } + + if len(c.redirectURIs) > 0 { + redirectAllowed := false + for _, allowed := range c.redirectURIs { + if allowed == redirectURI { + redirectAllowed = true + break + } + } + if !redirectAllowed { + http.Error(w, "unregistered redirect_uri", http.StatusBadRequest) + return + } + } + code := randomHex(32) h.mu.Lock() h.codes[code] = &authCode{clientID: clientID, scope: scope, createdAt: time.Now()} @@ -331,7 +355,7 @@ func (h *oauthHandler) handleRegister(w http.ResponseWriter, r *http.Request) { secret := "secret-" + randomHex(24) h.mu.Lock() - h.clients[id] = &client{id: id, secret: secret, scope: req.Scope} + h.clients[id] = &client{id: id, secret: secret, scope: req.Scope, redirectURIs: req.RedirectURIs} h.mu.Unlock() w.Header().Set("Content-Type", "application/json") diff --git a/router-tests/mcp_auth_e2e_test.go b/router-tests/mcp_auth_e2e_test.go index 896c2e4afb..690619b422 100644 --- a/router-tests/mcp_auth_e2e_test.go +++ b/router-tests/mcp_auth_e2e_test.go @@ -5,25 +5,11 @@ import ( "fmt" "net/http" "strings" - "testing" "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/wundergraph/cosmo/router-tests/testenv" "github.com/wundergraph/cosmo/router-tests/testutil" - "github.com/wundergraph/cosmo/router/pkg/config" ) -// previewToken returns a truncated preview of a token for logging purposes. -// Returns the full token if shorter than n characters, otherwise returns first n characters with "...". -func previewToken(token string, n int) string { - if len(token) <= n { - return token - } - return token[:n] + "..." -} - // authRoundTripper wraps an http.RoundTripper and adds Authorization headers // It also captures the last HTTP response for error analysis type authRoundTripper struct { @@ -74,24 +60,20 @@ func (e *AuthError) Error() string { // NewMCPAuthClient creates a new MCP client with authorization support func NewMCPAuthClient(endpoint string, initialToken string) *MCPAuthClient { - // Create a custom round tripper that adds Authorization headers roundTripper := &authRoundTripper{ base: http.DefaultTransport, token: initialToken, } - // Create HTTP client with custom round tripper httpClient := &http.Client{ Transport: roundTripper, } - // Create streamable transport transport := &mcp.StreamableClientTransport{ Endpoint: endpoint, HTTPClient: httpClient, } - // Create MCP client client := mcp.NewClient(&mcp.Implementation{ Name: "test-client", Version: "1.0.0", @@ -115,14 +97,13 @@ func (c *MCPAuthClient) Connect(ctx context.Context) error { return nil } -// SetToken updates the authorization token -// This is the KEY method - it allows changing tokens without reconnecting! +// SetToken updates the authorization token without reconnecting func (c *MCPAuthClient) SetToken(token string) { c.roundTripper.token = token } -// CallTool calls an MCP tool -// Returns *AuthError if the request fails due to HTTP 401/403 +// CallTool calls an MCP tool. +// Returns *AuthError if the request fails due to HTTP 401/403. func (c *MCPAuthClient) CallTool(ctx context.Context, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) { params := &mcp.CallToolParams{ Name: toolName, @@ -131,7 +112,6 @@ func (c *MCPAuthClient) CallTool(ctx context.Context, toolName string, arguments result, err := c.session.CallTool(ctx, params) if err != nil { - // Check if this was an HTTP auth error if authErr := c.checkAuthError(); authErr != nil { return nil, authErr } @@ -150,12 +130,10 @@ func (c *MCPAuthClient) checkAuthError() *AuthError { resp := c.roundTripper.lastResponse - // Check for 401 Unauthorized or 403 Forbidden if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusForbidden { return nil } - // Parse WWW-Authenticate header authHeader := resp.Header.Get("WWW-Authenticate") if authHeader == "" { return &AuthError{ @@ -173,7 +151,6 @@ func (c *MCPAuthClient) checkAuthError() *AuthError { ErrorDescription: params["error_description"], } - // Parse required scopes (space-separated) if scopeStr := params["scope"]; scopeStr != "" { authErr.RequiredScopes = strings.Fields(scopeStr) } @@ -187,184 +164,4 @@ func (c *MCPAuthClient) Close() error { return c.session.Close() } return nil -} - -// TestMCPAuthorizationWithOfficialSDK demonstrates authorization testing with the official MCP Go SDK -func TestMCPAuthorizationWithOfficialSDK(t *testing.T) { - t.Run("Basic connection with token", func(t *testing.T) { - testenv.Run(t, &testenv.Config{ - MCP: config.MCPConfiguration{ - Enabled: true, - }, - }, func(t *testing.T, xEnv *testenv.Environment) { - ctx := context.Background() - - // Create MCP client with initial token - token := "test-token-with-read-scopes" - mcpClient := NewMCPAuthClient(xEnv.GetMCPServerAddr(), token) - - // Connect and initialize - err := mcpClient.Connect(ctx) - require.NoError(t, err) - defer mcpClient.Close() //nolint:errcheck - - t.Logf("✓ Connected to MCP server with token: %s", previewToken(token, 20)) - - // Call a tool - result, err := mcpClient.CallTool(ctx, "execute_operation_my_employees", map[string]any{ - "criteria": map[string]any{}, - }) - - // Without authorization configured, this should work - require.NoError(t, err) - require.NotNil(t, result) - t.Logf("✓ Successfully called tool") - }) - }) - - t.Run("Scope upgrade on persistent session", func(t *testing.T) { - // This test demonstrates the KEY concept: - // - Establish session with token1 - // - Get "insufficient scopes" error - // - Update token (SetToken) - // - Retry on SAME session with new token - - testenv.Run(t, &testenv.Config{ - MCP: config.MCPConfiguration{ - Enabled: true, - // TODO: Add authorization configuration when implemented - }, - }, func(t *testing.T, xEnv *testenv.Environment) { - ctx := context.Background() - - // Step 1: Connect with limited token - readToken := "token-with-scope-mcp:tools:read" - mcpClient := NewMCPAuthClient(xEnv.GetMCPServerAddr(), readToken) - - err := mcpClient.Connect(ctx) - require.NoError(t, err) - defer mcpClient.Close() //nolint:errcheck - - t.Logf("✓ Step 1: Connected with read-only token") - t.Logf(" Token: %s", readToken[:30]+"...") - - // Step 2: Call read operation (should succeed) - result, err := mcpClient.CallTool(ctx, "execute_operation_my_employees", map[string]any{ - "criteria": map[string]any{}, - }) - require.NoError(t, err) - require.NotNil(t, result) - t.Logf("✓ Step 2: Read operation succeeded") - - // Step 3: Try write operation (should fail with insufficient scopes) - // NOTE: This would fail if authorization is configured - _, err = mcpClient.CallTool(ctx, "execute_operation_update_mood", map[string]any{ - "employeeID": 1, - "mood": "HAPPY", - }) - - // Without authorization, this succeeds. With authorization, check for scope error - if err != nil { - t.Logf("✓ Step 3: Write operation failed (expected with auth): %v", err) - - // In a real scenario with authorization: - // 1. Parse error to get required scopes - // 2. User goes through OAuth flow - // 3. Get new token with required scopes - - // Step 4: Update token on SAME session - writeToken := "token-with-scope-mcp:tools:read,mcp:tools:write" - mcpClient.SetToken(writeToken) - t.Logf("✓ Step 4: Updated token (same session)") - t.Logf(" New Token: %s", writeToken[:30]+"...") - - // Step 5: Retry write operation with upgraded token - result, err := mcpClient.CallTool(ctx, "execute_operation_update_mood", map[string]any{ - "employeeID": 1, - "mood": "HAPPY", - }) - - assert.NoError(t, err) - assert.NotNil(t, result) - t.Logf("✓ Step 5: Write operation succeeded with upgraded token") - } else { - t.Logf("✓ Step 3: Write operation succeeded (no authorization configured)") - } - }) - }) - - t.Run("Multiple token changes on same session", func(t *testing.T) { - testenv.Run(t, &testenv.Config{ - MCP: config.MCPConfiguration{ - Enabled: true, - }, - }, func(t *testing.T, xEnv *testenv.Environment) { - ctx := context.Background() - - mcpClient := NewMCPAuthClient(xEnv.GetMCPServerAddr(), "initial-token") - err := mcpClient.Connect(ctx) - require.NoError(t, err) - defer mcpClient.Close() //nolint:errcheck - - t.Logf("✓ Connected with initial token") - - // Simulate multiple scope upgrades - tokens := []string{ - "token-with-basic-scopes", - "token-with-read-scopes", - "token-with-write-scopes", - "token-with-admin-scopes", - } - - for i, token := range tokens { - mcpClient.SetToken(token) - - // Make a call with the new token - result, err := mcpClient.CallTool(ctx, "execute_operation_my_employees", map[string]any{ - "criteria": map[string]any{}, - }) - - require.NoError(t, err) - require.NotNil(t, result) - t.Logf("✓ Request %d succeeded with token: %s", i+1, previewToken(token, 25)) - } - - t.Logf("✓ All token changes worked on same session") - }) - }) -} - -// Example_mcpAuthorizationFlow shows how to use the auth client -func Example_mcpAuthorizationFlow() { - ctx := context.Background() - - // Create client with initial token - client := NewMCPAuthClient("http://localhost:3000/mcp", "initial-token") - defer client.Close() //nolint:errcheck - - // Connect - if err := client.Connect(ctx); err != nil { - panic(err) - } - - // Try to call a tool - _, err := client.CallTool(ctx, "some_tool", map[string]any{}) - - // If we get insufficient scopes error - if err != nil { - // 1. User goes through OAuth flow (not shown) - // 2. Get new token with more scopes - newToken := "token-with-more-scopes" - - // 3. Update token on SAME session - client.SetToken(newToken) - - // 4. Retry the tool call - _, err = client.CallTool(ctx, "some_tool", map[string]any{}) - if err != nil { - panic(err) - } - } - - fmt.Println("Success!") -} +} \ No newline at end of file diff --git a/router-tests/mcp_auth_harness_example.go b/router-tests/mcp_auth_harness_example.go deleted file mode 100644 index 42d9e1550b..0000000000 --- a/router-tests/mcp_auth_harness_example.go +++ /dev/null @@ -1,242 +0,0 @@ -package integration - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" -) - -// Example demonstrating the actual HTTP-level MCP authorization flow -// This shows how tokens are sent in HTTP headers, not JSON-RPC - -type MCPClient struct { - serverURL string - httpClient *http.Client - sessionID string // Persistent across requests -} - -// Step 1: Initialize - First HTTP POST with initial token -func (c *MCPClient) Initialize(ctx context.Context, token string) error { - // Create JSON-RPC initialize request - jsonRPCRequest := map[string]interface{}{ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": map[string]interface{}{ - "protocolVersion": "2024-11-05", - "clientInfo": map[string]string{ - "name": "test-client", - "version": "1.0.0", - }, - }, - } - - // HTTP POST #1 - req, _ := http.NewRequestWithContext(ctx, "POST", c.serverURL, toReader(jsonRPCRequest)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) // ← Token in HTTP header - - resp, err := c.httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() //nolint:errcheck - - // Extract session ID from HTTP response headers - c.sessionID = resp.Header.Get("Mcp-Session-Id") // ← Session ID from HTTP header - - fmt.Printf("✓ HTTP POST #1 - Initialize\n") - fmt.Printf(" Request Header: Authorization: Bearer %s\n", token[:20]+"...") - fmt.Printf(" Response Header: Mcp-Session-Id: %s\n", c.sessionID) - - return nil -} - -// Step 2: Call tool with initial token (limited scopes) -func (c *MCPClient) CallToolWithLimitedScopes(ctx context.Context, token string) error { - jsonRPCRequest := map[string]interface{}{ - "jsonrpc": "2.0", - "id": 2, - "method": "tools/call", - "params": map[string]interface{}{ - "name": "execute_operation_update_mood", - "arguments": map[string]interface{}{ - "employeeID": 1, - "mood": "HAPPY", - }, - }, - } - - // HTTP POST #2 - Same session, same token - req, _ := http.NewRequestWithContext(ctx, "POST", c.serverURL, toReader(jsonRPCRequest)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) // ← Same token - req.Header.Set("Mcp-Session-Id", c.sessionID) // ← Same session ID - - fmt.Printf("\n✓ HTTP POST #2 - Call tool (limited scopes)\n") - fmt.Printf(" Request Header: Authorization: Bearer %s\n", token[:20]+"...") - fmt.Printf(" Request Header: Mcp-Session-Id: %s\n", c.sessionID) - - resp, err := c.httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() //nolint:errcheck - - // Parse JSON-RPC response - var jsonRPCResp struct { - Error *struct { - Code int `json:"code"` - Message string `json:"message"` - Data struct { - RequiredScopes []string `json:"required_scopes"` // ← Scopes in JSON-RPC error data - } `json:"data"` - } `json:"error"` - } - json.NewDecoder(resp.Body).Decode(&jsonRPCResp) //nolint:errcheck - - if jsonRPCResp.Error != nil { - fmt.Printf(" Response Body: JSON-RPC Error\n") - fmt.Printf(" {\n") - fmt.Printf(" \"error\": {\n") - fmt.Printf(" \"code\": %d,\n", jsonRPCResp.Error.Code) - fmt.Printf(" \"message\": \"%s\",\n", jsonRPCResp.Error.Message) - fmt.Printf(" \"data\": {\n") - fmt.Printf(" \"required_scopes\": %v\n", jsonRPCResp.Error.Data.RequiredScopes) - fmt.Printf(" }\n") - fmt.Printf(" }\n") - fmt.Printf(" }\n") - return fmt.Errorf("insufficient scopes: %v", jsonRPCResp.Error.Data.RequiredScopes) - } - - return nil -} - -// Step 3: Obtain new token (simulated OAuth flow) -func (c *MCPClient) ObtainNewToken(requiredScopes []string) string { - // In reality, this would: - // 1. Open browser to authorization server - // 2. User consents to new scopes - // 3. Exchange auth code for new access token - // 4. Return new access token - - newToken := fmt.Sprintf("new-token-with-scopes-%v", requiredScopes) - fmt.Printf("\n✓ OAuth Flow - Obtained new token\n") - fmt.Printf(" Scopes: %v\n", requiredScopes) - fmt.Printf(" New Token: %s\n", newToken[:30]+"...") - return newToken -} - -// Step 4: Retry tool call with upgraded token -func (c *MCPClient) CallToolWithUpgradedToken(ctx context.Context, newToken string) error { - jsonRPCRequest := map[string]interface{}{ - "jsonrpc": "2.0", - "id": 3, - "method": "tools/call", - "params": map[string]interface{}{ - "name": "execute_operation_update_mood", - "arguments": map[string]interface{}{ - "employeeID": 1, - "mood": "HAPPY", - }, - }, - } - - // HTTP POST #3 - SAME session, DIFFERENT token - req, _ := http.NewRequestWithContext(ctx, "POST", c.serverURL, toReader(jsonRPCRequest)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", newToken)) // ← NEW token (different Authorization header) - req.Header.Set("Mcp-Session-Id", c.sessionID) // ← SAME session ID - - fmt.Printf("\n✓ HTTP POST #3 - Call tool (upgraded scopes)\n") - fmt.Printf(" Request Header: Authorization: Bearer %s ← DIFFERENT TOKEN\n", newToken[:30]+"...") - fmt.Printf(" Request Header: Mcp-Session-Id: %s ← SAME SESSION\n", c.sessionID) - - resp, err := c.httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() //nolint:errcheck - - fmt.Printf(" Response: %d OK\n", resp.StatusCode) - fmt.Printf(" Response Body: JSON-RPC Success\n") - - return nil -} - -func toReader(v interface{}) io.Reader { - b, _ := json.Marshal(v) - return bytes.NewReader(b) -} - -// ExampleAuthorizationFlow demonstrates the complete flow -func ExampleAuthorizationFlow() { - client := &MCPClient{ - serverURL: "http://localhost:3000/mcp", - httpClient: &http.Client{}, - } - - ctx := context.Background() - - // Step 1: Initialize with limited scopes - initialToken := "token-with-scopes-mcp:tools:read" - client.Initialize(ctx, initialToken) //nolint:errcheck - - // Step 2: Try to call write operation (will fail) - err := client.CallToolWithLimitedScopes(ctx, initialToken) - - // Step 3: Get new token with required scopes - if err != nil { - newToken := client.ObtainNewToken([]string{"mcp:tools:write"}) - - // Step 4: Retry with upgraded token (same session!) - _ = client.CallToolWithUpgradedToken(ctx, newToken) - } - - fmt.Printf("\n=== Summary ===\n") - fmt.Printf("• Session persists via Mcp-Session-Id HTTP header\n") - fmt.Printf("• Authorization changes via Authorization HTTP header\n") - fmt.Printf("• Each JSON-RPC request is a separate HTTP POST\n") - fmt.Printf("• HTTP headers carry auth/session, not JSON-RPC payload\n") -} - -/* -Expected Output: - -✓ HTTP POST #1 - Initialize - Request Header: Authorization: Bearer token-with-scopes-mc... - Response Header: Mcp-Session-Id: abc-123-def-456 - -✓ HTTP POST #2 - Call tool (limited scopes) - Request Header: Authorization: Bearer token-with-scopes-mc... - Request Header: Mcp-Session-Id: abc-123-def-456 - Response Body: JSON-RPC Error - { - "error": { - "code": -32001, - "message": "Insufficient permissions", - "data": { - "required_scopes": [mcp:tools:write] - } - } - } - -✓ OAuth Flow - Obtained new token - Scopes: [mcp:tools:write] - New Token: new-token-with-scopes-[mcp:too... - -✓ HTTP POST #3 - Call tool (upgraded scopes) - Request Header: Authorization: Bearer new-token-with-scopes-[mcp:too... ← DIFFERENT TOKEN - Request Header: Mcp-Session-Id: abc-123-def-456 ← SAME SESSION - Response: 200 OK - Response Body: JSON-RPC Success - -=== Summary === -• Session persists via Mcp-Session-Id HTTP header -• Authorization changes via Authorization HTTP header -• Each JSON-RPC request is a separate HTTP POST -• HTTP headers carry auth/session, not JSON-RPC payload -*/ diff --git a/router-tests/testutil/oauth_server.go b/router-tests/testutil/oauth_server.go index ea8353553c..315ed32076 100644 --- a/router-tests/testutil/oauth_server.go +++ b/router-tests/testutil/oauth_server.go @@ -24,6 +24,7 @@ type OAuthClient struct { ClientID string `json:"client_id"` ClientSecret string `json:"client_secret"` GrantTypes []string `json:"grant_types"` + RedirectURIs []string `json:"redirect_uris,omitempty"` Scope string `json:"scope,omitempty"` } @@ -286,6 +287,28 @@ func (s *OAuthTestServer) handleAuthorize(w http.ResponseWriter, r *http.Request return } + // Validate redirect_uri against registered URIs to prevent open redirects + s.mu.RLock() + c, ok := s.clients[clientID] + s.mu.RUnlock() + if !ok { + http.Error(w, "unknown client_id", http.StatusBadRequest) + return + } + if len(c.RedirectURIs) > 0 { + redirectAllowed := false + for _, allowed := range c.RedirectURIs { + if allowed == redirectURI { + redirectAllowed = true + break + } + } + if !redirectAllowed { + http.Error(w, "unregistered redirect_uri", http.StatusBadRequest) + return + } + } + // Generate authorization code code := randomString(32) @@ -334,6 +357,7 @@ func (s *OAuthTestServer) handleRegister(w http.ResponseWriter, r *http.Request) ClientID: clientID, ClientSecret: clientSecret, GrantTypes: req.GrantTypes, + RedirectURIs: req.RedirectURIs, Scope: req.Scope, } From 1b9a10c6f68a47faf2fc06d9df2e6eb45a7d5188 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 24 Mar 2026 20:58:41 +0000 Subject: [PATCH 04/46] refactor(mcp): simplify OAuth auth middleware and address review findings - Remove unused JWKSTestServer and redundant MCPScopeConfig struct - Remove enabled field from middleware (caller gates creation) - Extract token scopes once per request with set-based O(1) lookups - Consolidate scope challenge response methods into shared helper - Increase auth middleware body limit from 1MB to 10MB - Remove S256 PKCE claim from test OAuth server (not implemented) - Add env tags for OAuth scope configuration fields - Require JWKS in JSON schema when OAuth enabled - Fix refresh_unknown_kid schema defaults to match Go (max_wait 2m, interval 30s) - Always reset RequiredScopes in ComputeToolScopes to clear stale state - Fix RFC 9728 resource URL to include /mcp path - Make resource_documentation configurable at MCP level --- router-tests/testutil/jwt_helper.go | 195 --------------- router-tests/testutil/oauth_server.go | 1 - router/core/router.go | 5 +- router/go.mod | 12 +- router/go.sum | 20 +- router/pkg/config/config.go | 17 +- router/pkg/config/config.schema.json | 27 +- .../pkg/config/testdata/config_defaults.json | 3 +- router/pkg/config/testdata/config_full.json | 3 +- router/pkg/mcpserver/auth_middleware.go | 234 +++++------------- router/pkg/mcpserver/auth_middleware_test.go | 44 ++-- .../mcpserver/execute_graphql_scope_test.go | 9 +- router/pkg/mcpserver/operation_manager.go | 4 +- router/pkg/mcpserver/scope_challenge.go | 36 +-- router/pkg/mcpserver/scope_challenge_test.go | 2 +- router/pkg/mcpserver/server.go | 67 ++--- 16 files changed, 171 insertions(+), 508 deletions(-) delete mode 100644 router-tests/testutil/jwt_helper.go diff --git a/router-tests/testutil/jwt_helper.go b/router-tests/testutil/jwt_helper.go deleted file mode 100644 index 99b1929d62..0000000000 --- a/router-tests/testutil/jwt_helper.go +++ /dev/null @@ -1,195 +0,0 @@ -package testutil - -import ( - "context" - "fmt" - "net/http" - "testing" - "time" - - "github.com/MicahParks/jwkset" - "github.com/golang-jwt/jwt/v5" - "github.com/wundergraph/cosmo/router-tests/freeport" - "github.com/wundergraph/cosmo/router-tests/jwks" -) - -// JWKSTestServer provides JWT token generation for testing -type JWKSTestServer struct { - t *testing.T - provider jwks.Crypto - keyID string - issuer string - audience string - jwksURL string - server *http.Server - storage jwkset.Storage -} - -// NewJWKSTestServer creates a new JWKS test server with RSA keys -// The server will automatically allocate a free port and return it when the test ends -func NewJWKSTestServer(t *testing.T) (*JWKSTestServer, error) { - t.Helper() - - // Get a free port using the freeport package - port := freeport.GetOne(t) - portStr := fmt.Sprintf("%d", port) - - keyID := "test_rsa" - provider, err := jwks.NewRSACrypto(keyID, jwkset.AlgRS256, 2048) - if err != nil { - return nil, fmt.Errorf("failed to create RSA crypto: %w", err) - } - - storage := jwkset.NewMemoryStorage() - ctx := context.Background() - - jwk, err := provider.MarshalJWK() - if err != nil { - return nil, fmt.Errorf("failed to marshal JWK: %w", err) - } - - if err := storage.KeyWrite(ctx, jwk); err != nil { - return nil, fmt.Errorf("failed to write key to storage: %w", err) - } - - server := &JWKSTestServer{ - t: t, - provider: provider, - keyID: keyID, - issuer: fmt.Sprintf("http://localhost:%s", portStr), - audience: "test-audience", - jwksURL: fmt.Sprintf("http://localhost:%s/.well-known/jwks.json", portStr), - storage: storage, - } - - // Start HTTP server - mux := http.NewServeMux() - mux.HandleFunc("/.well-known/jwks.json", server.handleJWKS) - - httpServer := &http.Server{ - Addr: ":" + portStr, - Handler: mux, - } - - server.server = httpServer - - go func() { - if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - t.Logf("JWKS server error: %v", err) - } - }() - - // Wait for server to start - if err := server.waitForReady(5 * time.Second); err != nil { - return nil, fmt.Errorf("JWKS server failed to start: %w", err) - } - - t.Logf("JWKS test server started at %s", server.issuer) - - return server, nil -} - -// waitForReady waits for the server to be ready -func (s *JWKSTestServer) waitForReady(timeout time.Duration) error { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return fmt.Errorf("timeout waiting for JWKS server") - case <-ticker.C: - req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.jwksURL, nil) - if err != nil { - continue - } - resp, err := http.DefaultClient.Do(req) - if err == nil { - resp.Body.Close() - if resp.StatusCode == http.StatusOK { - return nil - } - } - } - } -} - -// handleJWKS serves the JWKS JSON -func (s *JWKSTestServer) handleJWKS(w http.ResponseWriter, r *http.Request) { - ctx := context.Background() - rawJWKS, err := s.storage.JSON(ctx) - if err != nil { - s.t.Logf("Failed to get JWKS: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - w.Write(rawJWKS) -} - -// CreateToken creates a JWT token with the specified claims -// Default claims (iss, aud, iat, exp) are added automatically -func (s *JWKSTestServer) CreateToken(claims map[string]any) (string, error) { - s.t.Helper() - - now := time.Now() - tokenClaims := jwt.MapClaims{ - "iss": s.issuer, - "aud": s.audience, - "iat": now.Unix(), - "exp": now.Add(1 * time.Hour).Unix(), - } - - // Merge custom claims - for k, v := range claims { - tokenClaims[k] = v - } - - token := jwt.NewWithClaims(s.provider.SigningMethod(), tokenClaims) - token.Header[jwkset.HeaderKID] = s.keyID - - signed, err := token.SignedString(s.provider.PrivateKey()) - if err != nil { - return "", fmt.Errorf("failed to sign token: %w", err) - } - - return signed, nil -} - -// CreateTokenWithScopes creates a token with specific OAuth scopes -func (s *JWKSTestServer) CreateTokenWithScopes(sub string, scopes []string) (string, error) { - s.t.Helper() - - scopeStr := "" - if len(scopes) > 0 { - scopeStr = scopes[0] - for i := 1; i < len(scopes); i++ { - scopeStr += " " + scopes[i] - } - } - - return s.CreateToken(map[string]any{ - "sub": sub, - "scope": scopeStr, - }) -} - -// JWKSURL returns the URL of the JWKS endpoint -func (s *JWKSTestServer) JWKSURL() string { - return s.jwksURL -} - -// Issuer returns the issuer URL -func (s *JWKSTestServer) Issuer() string { - return s.issuer -} - -// Close stops the JWKS server -func (s *JWKSTestServer) Close() error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - return s.server.Shutdown(ctx) -} diff --git a/router-tests/testutil/oauth_server.go b/router-tests/testutil/oauth_server.go index 315ed32076..751e98345d 100644 --- a/router-tests/testutil/oauth_server.go +++ b/router-tests/testutil/oauth_server.go @@ -168,7 +168,6 @@ func (s *OAuthTestServer) handleASMetadata(w http.ResponseWriter, _ *http.Reques "response_types_supported": []string{"code"}, "grant_types_supported": []string{"client_credentials", "authorization_code"}, "token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"}, - "code_challenge_methods_supported": []string{"S256"}, } w.Header().Set("Content-Type", "application/json") diff --git a/router/core/router.go b/router/core/router.go index f4e63a10b0..9bc399efe2 100644 --- a/router/core/router.go +++ b/router/core/router.go @@ -982,12 +982,15 @@ func (r *Router) bootstrap(ctx context.Context) error { if r.mcp.OAuth.Enabled { mcpOpts = append(mcpOpts, mcpserver.WithOAuth(&r.mcp.OAuth)) - // Add server base URL for OAuth discovery if configured if r.mcp.Server.BaseURL != "" { mcpOpts = append(mcpOpts, mcpserver.WithServerBaseURL(r.mcp.Server.BaseURL)) } } + if r.mcp.ResourceDocumentation != "" { + mcpOpts = append(mcpOpts, mcpserver.WithResourceDocumentation(r.mcp.ResourceDocumentation)) + } + mcpGraphQLEndpoint := r.graphqlEndpointURL if r.mcp.RouterURL != "" { mcpGraphQLEndpoint = r.mcp.RouterURL diff --git a/router/go.mod b/router/go.mod index 67c3df1996..c1d8abe25d 100644 --- a/router/go.mod +++ b/router/go.mod @@ -1,6 +1,6 @@ module github.com/wundergraph/cosmo/router -go 1.25 +go 1.25.0 require ( connectrpc.com/connect v1.16.2 @@ -13,7 +13,7 @@ require ( github.com/go-redis/redis_rate/v10 v10.0.1 github.com/gobwas/ws v1.4.0 github.com/goccy/go-yaml v1.17.1 - github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/gorilla/websocket v1.5.1 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-retryablehttp v0.7.7 @@ -51,7 +51,7 @@ require ( go.uber.org/zap v1.27.0 go.withmatt.com/connect-brotli v0.4.0 golang.org/x/sync v0.17.0 - golang.org/x/sys v0.40.0 // indirect + golang.org/x/sys v0.41.0 // indirect google.golang.org/grpc v1.71.0 google.golang.org/protobuf v1.36.9 ) @@ -94,8 +94,8 @@ require ( github.com/frankban/quicktest v1.14.6 // indirect github.com/google/jsonschema-go v0.4.2 // indirect github.com/segmentio/asm v1.1.3 // indirect - github.com/segmentio/encoding v0.5.3 // indirect - golang.org/x/oauth2 v0.34.0 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + golang.org/x/oauth2 v0.35.0 // indirect ) require ( @@ -138,7 +138,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect - github.com/modelcontextprotocol/go-sdk v1.4.0 + github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nats-io/nkeys v0.4.7 // indirect github.com/oklog/run v1.0.0 // indirect diff --git a/router/go.sum b/router/go.sum index c8d000743e..a084404994 100644 --- a/router/go.sum +++ b/router/go.sum @@ -110,8 +110,8 @@ github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -201,8 +201,8 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8= -github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E= +github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= +github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nats-io/nats.go v1.35.0 h1:XFNqNM7v5B+MQMKqVGAyHwYhyKb48jrenXNxIU20ULk= @@ -263,8 +263,8 @@ github.com/sebdah/goldie/v2 v2.7.1 h1:PkBHymaYdtvEkZV7TmyqKxdmn5/Vcj+8TpATWZjnG5 github.com/sebdah/goldie/v2 v2.7.1/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= -github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= -github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= @@ -397,8 +397,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= @@ -418,8 +418,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index de2b49d5a8..c4b728de2f 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -1125,6 +1125,9 @@ type MCPConfiguration struct { // When enabled, GetUser becomes get_user. When disabled (default), GetUser becomes execute_operation_get_user. OmitToolNamePrefix bool `yaml:"omit_tool_name_prefix" envDefault:"false" env:"MCP_OMIT_TOOL_NAME_PREFIX"` OAuth MCPOAuthConfiguration `yaml:"oauth,omitempty" envPrefix:"MCP_OAUTH_"` + // ResourceDocumentation is a URL to a human-readable page describing this MCP resource, + // its access policies, and how to get started. Included in RFC 9728 Protected Resource Metadata if set. + ResourceDocumentation string `yaml:"resource_documentation,omitempty" env:"MCP_RESOURCE_DOCUMENTATION"` } type MCPOAuthConfiguration struct { @@ -1132,7 +1135,7 @@ type MCPOAuthConfiguration struct { JWKS []JWKSConfiguration `yaml:"jwks"` AuthorizationServerURL string `yaml:"authorization_server_url,omitempty" env:"AUTHORIZATION_SERVER_URL"` // Scopes configures which OAuth scopes are required for different MCP operations. - Scopes MCPOAuthScopesConfiguration `yaml:"scopes,omitempty"` + Scopes MCPOAuthScopesConfiguration `yaml:"scopes,omitempty" envPrefix:"SCOPES_"` // ScopeChallengeIncludeTokenScopes controls whether the server includes the token's existing scopes // in the scope parameter of 403 insufficient_scope responses. // When false (default), only the scopes required for the operation are returned (RFC 6750 strict). @@ -1146,20 +1149,20 @@ type MCPOAuthConfiguration struct { type MCPOAuthScopesConfiguration struct { // Initialize specifies scopes required for ALL HTTP requests (checked before JSON-RPC parsing). // This is the baseline scope needed to establish an MCP connection. - Initialize []string `yaml:"initialize,omitempty"` + Initialize []string `yaml:"initialize,omitempty" env:"INITIALIZE"` // ToolsList specifies scopes required for the tools/list MCP method. - ToolsList []string `yaml:"tools_list,omitempty"` + ToolsList []string `yaml:"tools_list,omitempty" env:"TOOLS_LIST"` // ToolsCall specifies scopes required for the tools/call MCP method (any tool). - ToolsCall []string `yaml:"tools_call,omitempty"` + ToolsCall []string `yaml:"tools_call,omitempty" env:"TOOLS_CALL"` // ExecuteGraphQL specifies scopes required to call the execute_graphql built-in tool. // Additive to tools_call scopes. Only relevant when enable_arbitrary_operations is true. - ExecuteGraphQL []string `yaml:"execute_graphql,omitempty"` + ExecuteGraphQL []string `yaml:"execute_graphql,omitempty" env:"EXECUTE_GRAPHQL"` // GetOperationInfo specifies scopes required to call the get_operation_info built-in tool. // Additive to tools_call scopes. - GetOperationInfo []string `yaml:"get_operation_info,omitempty"` + GetOperationInfo []string `yaml:"get_operation_info,omitempty" env:"GET_OPERATION_INFO"` // GetSchema specifies scopes required to call the get_schema built-in tool. // Additive to tools_call scopes. Only relevant when expose_schema is true. - GetSchema []string `yaml:"get_schema,omitempty"` + GetSchema []string `yaml:"get_schema,omitempty" env:"GET_SCHEMA"` } type MCPSessionConfig struct { diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index b04134544f..c41c4c9d9f 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -2041,7 +2041,7 @@ "max_wait": { "type": "string", "description": "Maximum time to wait for a refresh permit before giving up.", - "default": "10s", + "default": "2m", "duration": { "minimum": "0s" } @@ -2049,7 +2049,7 @@ "interval": { "type": "string", "description": "Token refill interval for the rate limiter.", - "default": "1m", + "default": "30s", "duration": { "minimum": "1s" } @@ -2409,6 +2409,11 @@ "default": false, "description": "When enabled, MCP tool names generated from GraphQL operations omit the 'execute_operation_' prefix. For example, the GraphQL operation 'GetUser' results in a tool named 'get_user' instead of 'execute_operation_get_user'." }, + "resource_documentation": { + "type": "string", + "description": "A URL to a human-readable page describing this MCP resource, its access policies, and how to get started. Included in the RFC 9728 Protected Resource Metadata response if set.", + "format": "http-url" + }, "oauth": { "type": "object", "description": "OAuth/JWKS authentication configuration for the MCP server. When enabled, MCP tool calls require valid JWT authentication and the server implements OAuth 2.0 discovery mechanisms (RFC 8414, RFC 9728).", @@ -2554,7 +2559,7 @@ "max_wait": { "type": "string", "description": "Maximum time to wait for a refresh permit before giving up.", - "default": "10s", + "default": "2m", "duration": { "minimum": "0s" } @@ -2562,7 +2567,7 @@ "interval": { "type": "string", "description": "Token refill interval for the rate limiter.", - "default": "1m", + "default": "30s", "duration": { "minimum": "1s" } @@ -2633,9 +2638,17 @@ "properties": { "server": { "required": ["base_url"] + }, + "oauth": { + "properties": { + "jwks": { + "minItems": 1 + } + }, + "required": ["jwks"] } }, - "required": ["server"] + "required": ["server", "oauth"] } }, "connect_rpc": { @@ -4006,7 +4019,7 @@ "max_wait": { "type": "string", "description": "Maximum time to wait for a refresh permit before giving up.", - "default": "10s", + "default": "2m", "duration": { "minimum": "0s" } @@ -4014,7 +4027,7 @@ "interval": { "type": "string", "description": "Token refill interval for the rate limiter.", - "default": "1m", + "default": "30s", "duration": { "minimum": "1s" } diff --git a/router/pkg/config/testdata/config_defaults.json b/router/pkg/config/testdata/config_defaults.json index 572ca4411d..78f176729d 100644 --- a/router/pkg/config/testdata/config_defaults.json +++ b/router/pkg/config/testdata/config_defaults.json @@ -178,7 +178,8 @@ "GetSchema": null }, "ScopeChallengeIncludeTokenScopes": false - } + }, + "ResourceDocumentation": "" }, "ConnectRPC": { "Enabled": false, diff --git a/router/pkg/config/testdata/config_full.json b/router/pkg/config/testdata/config_full.json index 96dde9ad7a..cfc529a829 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -223,7 +223,8 @@ "GetSchema": null }, "ScopeChallengeIncludeTokenScopes": false - } + }, + "ResourceDocumentation": "" }, "ConnectRPC": { "Enabled": false, diff --git a/router/pkg/mcpserver/auth_middleware.go b/router/pkg/mcpserver/auth_middleware.go index dea66baaa1..e23b25f1b6 100644 --- a/router/pkg/mcpserver/auth_middleware.go +++ b/router/pkg/mcpserver/auth_middleware.go @@ -7,11 +7,11 @@ import ( "fmt" "io" "net/http" - "slices" "strings" "sync" "github.com/wundergraph/cosmo/router/pkg/authentication" + "github.com/wundergraph/cosmo/router/pkg/config" "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" ) @@ -20,9 +20,8 @@ type contextKey string const ( userClaimsContextKey contextKey = "mcp_user_claims" - // maxBodyBytes is the maximum size of the request body we'll read for scope checking. - // This prevents memory exhaustion from oversized payloads. - maxBodyBytes int64 = 1 << 20 // 1 MB + // maxBodyBytes prevents memory exhaustion from oversized payloads. + maxBodyBytes int64 = 10 << 20 // 10 MB ) // mcpAuthProvider adapts MCP headers to the authentication.Provider interface @@ -34,43 +33,27 @@ func (p *mcpAuthProvider) AuthenticationHeaders() http.Header { return p.headers } -// MCPScopeConfig holds the structured scope requirements for MCP operations. -type MCPScopeConfig struct { - Initialize []string // Scopes required for all HTTP requests - ToolsList []string // Scopes required for tools/list - ToolsCall []string // Scopes required for tools/call (any tool) - ExecuteGraphQL []string // Scopes required for the execute_graphql built-in tool - GetOperationInfo []string // Scopes required for the get_operation_info built-in tool - GetSchema []string // Scopes required for the get_schema built-in tool -} - -// MCPAuthMiddleware creates authentication middleware for MCP tools and resources +// MCPAuthMiddleware provides HTTP-level authentication and scope enforcement for MCP. type MCPAuthMiddleware struct { authenticator authentication.Authenticator - enabled bool resourceMetadataURL string - scopes MCPScopeConfig + scopes config.MCPOAuthScopesConfiguration scopeChallengeIncludeTokenScopes bool toolScopesMu sync.RWMutex toolScopes map[string][][]string // toolName → OR-of-AND scope groups scopeExtractorMu sync.RWMutex - scopeExtractor *ScopeExtractor // for runtime scope checking of execute_graphql + scopeExtractor *ScopeExtractor } -// NewMCPAuthMiddleware creates a new authentication middleware using the existing -// authentication infrastructure from the router -func NewMCPAuthMiddleware(tokenDecoder authentication.TokenDecoder, enabled bool, resourceMetadataURL string, scopes MCPScopeConfig, scopeChallengeIncludeTokenScopes bool) (*MCPAuthMiddleware, error) { +// NewMCPAuthMiddleware creates a new authentication middleware. +func NewMCPAuthMiddleware(tokenDecoder authentication.TokenDecoder, resourceMetadataURL string, scopes config.MCPOAuthScopesConfiguration, scopeChallengeIncludeTokenScopes bool) (*MCPAuthMiddleware, error) { if tokenDecoder == nil { return nil, fmt.Errorf("token decoder must be provided") } - // Use the existing HttpHeaderAuthenticator with default settings (Authorization header, Bearer prefix) - // This ensures consistency with the rest of the router's authentication logic authenticator, err := authentication.NewHttpHeaderAuthenticator(authentication.HttpHeaderAuthenticatorOptions{ Name: "mcp-auth", TokenDecoder: tokenDecoder, - // HeaderSourcePrefixes defaults to {"Authorization": {"Bearer"}} when not specified - // This can be extended in the future to support additional schemes like DPoP }) if err != nil { return nil, fmt.Errorf("failed to create authenticator: %w", err) @@ -78,7 +61,6 @@ func NewMCPAuthMiddleware(tokenDecoder authentication.TokenDecoder, enabled bool return &MCPAuthMiddleware{ authenticator: authenticator, - enabled: enabled, resourceMetadataURL: resourceMetadataURL, scopes: scopes, scopeChallengeIncludeTokenScopes: scopeChallengeIncludeTokenScopes, @@ -86,15 +68,12 @@ func NewMCPAuthMiddleware(tokenDecoder authentication.TokenDecoder, enabled bool } // SetToolScopes atomically replaces the per-tool scope map. -// Called during Reload() after tools are registered with their extracted scopes. func (m *MCPAuthMiddleware) SetToolScopes(scopes map[string][][]string) { m.toolScopesMu.Lock() defer m.toolScopesMu.Unlock() m.toolScopes = scopes } -// getToolScopes returns the OR-of-AND scope groups for the given tool name. -// Returns nil if the tool has no per-tool scope requirements. func (m *MCPAuthMiddleware) getToolScopes(toolName string) [][]string { m.toolScopesMu.RLock() defer m.toolScopesMu.RUnlock() @@ -104,8 +83,6 @@ func (m *MCPAuthMiddleware) getToolScopes(toolName string) [][]string { return m.toolScopes[toolName] } -// getBuiltinToolScopes returns the configured scopes for a built-in tool. -// Returns nil if the tool is not a built-in or has no configured scopes. func (m *MCPAuthMiddleware) getBuiltinToolScopes(toolName string) []string { switch toolName { case "execute_graphql": @@ -121,49 +98,42 @@ func (m *MCPAuthMiddleware) getBuiltinToolScopes(toolName string) []string { // SetScopeExtractor atomically replaces the scope extractor used for // runtime scope checking of execute_graphql arbitrary operations. -// Called during Reload() after the schema is loaded. func (m *MCPAuthMiddleware) SetScopeExtractor(extractor *ScopeExtractor) { m.scopeExtractorMu.Lock() defer m.scopeExtractorMu.Unlock() m.scopeExtractor = extractor } -// getScopeExtractor returns the current scope extractor (thread-safe). func (m *MCPAuthMiddleware) getScopeExtractor() *ScopeExtractor { m.scopeExtractorMu.RLock() defer m.scopeExtractorMu.RUnlock() return m.scopeExtractor } -// HTTPMiddleware wraps HTTP handlers with authentication for ALL MCP operations -// Per MCP specification: "authorization MUST be included in every HTTP request from client to server" +// HTTPMiddleware wraps HTTP handlers with authentication for ALL MCP operations. +// Per MCP spec: "authorization MUST be included in every HTTP request from client to server" func (m *MCPAuthMiddleware) HTTPMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !m.enabled { - next.ServeHTTP(w, r) - return - } - - // Create a provider from the HTTP request headers provider := &mcpAuthProvider{headers: r.Header} - // Validate the token claims, err := m.authenticator.Authenticate(r.Context(), provider) if err != nil || len(claims) == 0 { m.sendUnauthorizedResponse(w, err) return } - // Step 1: Validate HTTP-level required scopes (initialize) + // Extract token scopes once for all checks in this request + tokenScopes := extractScopes(claims) + tokenScopeSet := toSet(tokenScopes) + if len(m.scopes.Initialize) > 0 { - if err := m.validateScopesForRequest(claims, m.scopes.Initialize); err != nil { - m.sendInsufficientScopeResponse(w, m.scopes.Initialize, claims, err) + if missing := findMissing(tokenScopeSet, m.scopes.Initialize); len(missing) > 0 { + m.sendInsufficientScopeResponse(w, m.scopes.Initialize, tokenScopes, missing) return } } - // Step 2: Parse JSON-RPC request to check method-level scopes - // Only parse body for POST requests with JSON content (SSE/GET requests have no JSON-RPC body) + // Parse JSON-RPC body for method-level scope checks (SSE/GET requests have no body) var body []byte if r.Method == http.MethodPost && r.Body != nil { limitedReader := io.LimitReader(r.Body, maxBodyBytes+1) @@ -176,11 +146,9 @@ func (m *MCPAuthMiddleware) HTTPMiddleware(next http.Handler) http.Handler { m.sendUnauthorizedResponse(w, fmt.Errorf("request body too large")) return } - // Restore body for downstream handlers r.Body = io.NopCloser(bytes.NewBuffer(body)) } - // Try to parse as JSON-RPC request (only if we have body content) if len(body) > 0 { var jsonRPCReq struct { Method string `json:"method"` @@ -190,7 +158,7 @@ func (m *MCPAuthMiddleware) HTTPMiddleware(next http.Handler) http.Handler { } `json:"params"` } if err := json.Unmarshal(body, &jsonRPCReq); err == nil && jsonRPCReq.Method != "" { - // Check method-level scopes + // Method-level scope check var methodScopes []string switch jsonRPCReq.Method { case "tools/list": @@ -199,38 +167,37 @@ func (m *MCPAuthMiddleware) HTTPMiddleware(next http.Handler) http.Handler { methodScopes = m.scopes.ToolsCall } if len(methodScopes) > 0 { - if err := m.validateScopesForRequest(claims, methodScopes); err != nil { - m.sendInsufficientScopeResponse(w, methodScopes, claims, err) + if missing := findMissing(tokenScopeSet, methodScopes); len(missing) > 0 { + m.sendInsufficientScopeResponse(w, methodScopes, tokenScopes, missing) return } } - // Built-in tool scope check (additive to tools_call gate) if jsonRPCReq.Method == "tools/call" && jsonRPCReq.Params.Name != "" { - if builtinScopes := m.getBuiltinToolScopes(jsonRPCReq.Params.Name); len(builtinScopes) > 0 { - if err := m.validateScopesForRequest(claims, builtinScopes); err != nil { - m.sendInsufficientScopeResponse(w, builtinScopes, claims, err) + toolName := jsonRPCReq.Params.Name + + // Built-in tool scope check (additive to tools_call gate) + if builtinScopes := m.getBuiltinToolScopes(toolName); len(builtinScopes) > 0 { + if missing := findMissing(tokenScopeSet, builtinScopes); len(missing) > 0 { + m.sendInsufficientScopeResponse(w, builtinScopes, tokenScopes, missing) return } } - } - // Per-tool scope check for tools/call (additive to static tools_call gate) - if jsonRPCReq.Method == "tools/call" && jsonRPCReq.Params.Name != "" { - if toolOrScopes := m.getToolScopes(jsonRPCReq.Params.Name); len(toolOrScopes) > 0 { - tokenScopes := extractScopes(claims) - if !SatisfiesAnyGroup(tokenScopes, toolOrScopes) { + // Per-tool scope check from @requiresScopes directives + if toolOrScopes := m.getToolScopes(toolName); len(toolOrScopes) > 0 { + if !SatisfiesAnyGroup(tokenScopeSet, toolOrScopes) { challengeScopes := BestScopeChallengeWithExisting(tokenScopes, toolOrScopes, m.scopeChallengeIncludeTokenScopes) - m.sendPerToolInsufficientScopeResponse(w, challengeScopes, jsonRPCReq.Params.Name) + m.sendPerToolInsufficientScopeResponse(w, challengeScopes, toolName) return } } - // Runtime scope check for execute_graphql: parse the query from arguments - // and extract @requiresScopes at the HTTP level (proper 403 + WWW-Authenticate) - if jsonRPCReq.Params.Name == "execute_graphql" && len(jsonRPCReq.Params.Arguments) > 0 { + // Runtime scope check for execute_graphql: parse the query and + // extract @requiresScopes at the HTTP level (proper 403 + WWW-Authenticate) + if toolName == "execute_graphql" && len(jsonRPCReq.Params.Arguments) > 0 { if extractor := m.getScopeExtractor(); extractor != nil { - if challengeScopes := m.checkExecuteGraphQLScopes(claims, jsonRPCReq.Params.Arguments, extractor); len(challengeScopes) > 0 { + if challengeScopes := m.checkExecuteGraphQLScopes(tokenScopes, tokenScopeSet, jsonRPCReq.Params.Arguments, extractor); len(challengeScopes) > 0 { m.sendPerToolInsufficientScopeResponse(w, challengeScopes, "execute_graphql") return } @@ -240,32 +207,22 @@ func (m *MCPAuthMiddleware) HTTPMiddleware(next http.Handler) http.Handler { } } - // Add claims and request headers to request context for downstream handlers ctx := context.WithValue(r.Context(), userClaimsContextKey, claims) ctx = requestHeadersFromRequest(ctx, r) next.ServeHTTP(w, r.WithContext(ctx)) }) } -// sendUnauthorizedResponse sends a 401 Unauthorized response with proper headers. -// It includes the minimum required scopes (from initialize) so that the MCP SDK -// can request exactly the scopes needed to establish a connection. +// sendUnauthorizedResponse sends a 401 with WWW-Authenticate per RFC 6750 and RFC 9728. func (m *MCPAuthMiddleware) sendUnauthorizedResponse(w http.ResponseWriter, err error) { - // Build WWW-Authenticate header per RFC 6750 and RFC 9728 authHeader := `Bearer realm="mcp"` - // Include minimum required scopes (initialize scopes) so the client knows - // what scopes to request for initial authentication if len(m.scopes.Initialize) > 0 { authHeader += fmt.Sprintf(`, scope="%s"`, strings.Join(m.scopes.Initialize, " ")) } - - // Add resource_metadata per RFC 9728 for OAuth discovery if m.resourceMetadataURL != "" { authHeader += fmt.Sprintf(`, resource_metadata="%s"`, m.resourceMetadataURL) } - - // Add optional error_description for debugging (sanitize quotes per RFC 6750 quoted-string) if err != nil { desc := strings.ReplaceAll(err.Error(), `"`, `'`) authHeader += fmt.Sprintf(`, error_description="%s"`, desc) @@ -273,76 +230,34 @@ func (m *MCPAuthMiddleware) sendUnauthorizedResponse(w http.ResponseWriter, err w.Header().Set("WWW-Authenticate", authHeader) w.WriteHeader(http.StatusUnauthorized) - - // Per MCP spec: Authorization failures at HTTP level return only HTTP status and WWW-Authenticate header - // No JSON-RPC response body is returned } -// sendInsufficientScopeResponse sends a 403 Forbidden response per RFC 6750 Section 3.1 -// when the token is valid but lacks required scopes. -// -// When scopeChallengeIncludeTokenScopes is false (default), only the scopes required for the -// operation are returned (RFC 6750 strict). When true, the token's existing scopes are unioned -// with the required scopes to work around client SDKs that replace rather than accumulate scopes. -func (m *MCPAuthMiddleware) sendInsufficientScopeResponse(w http.ResponseWriter, operationScopes []string, claims authentication.Claims, err error) { +// sendInsufficientScopeResponse sends a 403 per RFC 6750 Section 3.1. +// When scopeChallengeIncludeTokenScopes is true, the challenge includes the token's +// existing scopes to work around client SDKs that replace rather than accumulate scopes. +func (m *MCPAuthMiddleware) sendInsufficientScopeResponse(w http.ResponseWriter, operationScopes []string, tokenScopes []string, missingScopes []string) { challengeScopes := operationScopes - if m.scopeChallengeIncludeTokenScopes { - // Union of token's existing scopes + operation's required scopes. - // Existing scopes come first so the client retains them on re-auth. - existing := extractScopes(claims) - seen := make(map[string]struct{}, len(existing)+len(operationScopes)) - combined := make([]string, 0, len(existing)+len(operationScopes)) - for _, s := range existing { - seen[s] = struct{}{} - combined = append(combined, s) - } - for _, s := range operationScopes { - if _, ok := seen[s]; !ok { - seen[s] = struct{}{} - combined = append(combined, s) - } - } - challengeScopes = combined - } - - scopeList := strings.Join(challengeScopes, " ") - - // Build WWW-Authenticate header with error and scope information - // Per RFC 6750 Section 3.1 and MCP spec: error, scope, resource_metadata, error_description - authHeader := fmt.Sprintf(`Bearer error="insufficient_scope", scope="%s"`, scopeList) - - // Add resource_metadata per MCP spec (should be included per spec line 513) - if m.resourceMetadataURL != "" { - authHeader += fmt.Sprintf(`, resource_metadata="%s"`, m.resourceMetadataURL) - } - - // Add optional error_description for human-readable message (sanitize quotes per RFC 6750 quoted-string) - if err != nil { - desc := strings.ReplaceAll(err.Error(), `"`, `'`) - authHeader += fmt.Sprintf(`, error_description="%s"`, desc) + challengeScopes = mergeAndDedup(tokenScopes, operationScopes) } - w.Header().Set("WWW-Authenticate", authHeader) - w.WriteHeader(http.StatusForbidden) - - // Per MCP spec: Authorization failures at HTTP level return only HTTP status and WWW-Authenticate header - // No JSON-RPC response body is returned + desc := strings.ReplaceAll(fmt.Sprintf("missing required scopes: %s", strings.Join(missingScopes, ", ")), `"`, `'`) + m.writeScopeChallenge(w, challengeScopes, desc) } -// sendPerToolInsufficientScopeResponse sends a 403 response for per-tool scope failures. -// The challengeScopes have already been computed by BestScopeChallengeWithExisting. +// sendPerToolInsufficientScopeResponse sends a 403 for per-tool scope failures. func (m *MCPAuthMiddleware) sendPerToolInsufficientScopeResponse(w http.ResponseWriter, challengeScopes []string, toolName string) { - scopeList := strings.Join(challengeScopes, " ") - - authHeader := fmt.Sprintf(`Bearer error="insufficient_scope", scope="%s"`, scopeList) + sanitizedName := strings.ReplaceAll(toolName, `"`, `'`) + m.writeScopeChallenge(w, challengeScopes, fmt.Sprintf("insufficient scopes for tool %s", sanitizedName)) +} +// writeScopeChallenge writes a 403 with a WWW-Authenticate Bearer challenge. +func (m *MCPAuthMiddleware) writeScopeChallenge(w http.ResponseWriter, scopes []string, errorDescription string) { + authHeader := fmt.Sprintf(`Bearer error="insufficient_scope", scope="%s"`, strings.Join(scopes, " ")) if m.resourceMetadataURL != "" { authHeader += fmt.Sprintf(`, resource_metadata="%s"`, m.resourceMetadataURL) } - - sanitizedName := strings.ReplaceAll(toolName, `"`, `'`) - authHeader += fmt.Sprintf(`, error_description="insufficient scopes for tool %s"`, sanitizedName) + authHeader += fmt.Sprintf(`, error_description="%s"`, errorDescription) w.Header().Set("WWW-Authenticate", authHeader) w.WriteHeader(http.StatusForbidden) @@ -350,8 +265,7 @@ func (m *MCPAuthMiddleware) sendPerToolInsufficientScopeResponse(w http.Response // checkExecuteGraphQLScopes parses the GraphQL query from execute_graphql arguments, // extracts @requiresScopes requirements, and returns the challenge scopes if insufficient. -// Returns nil if scopes are satisfied or the query cannot be parsed. -func (m *MCPAuthMiddleware) checkExecuteGraphQLScopes(claims authentication.Claims, arguments json.RawMessage, extractor *ScopeExtractor) []string { +func (m *MCPAuthMiddleware) checkExecuteGraphQLScopes(tokenScopes []string, tokenScopeSet map[string]struct{}, arguments json.RawMessage, extractor *ScopeExtractor) []string { var args struct { Query string `json:"query"` } @@ -374,60 +288,38 @@ func (m *MCPAuthMiddleware) checkExecuteGraphQLScopes(claims authentication.Clai return nil } - tokenScopes := extractScopes(claims) - if SatisfiesAnyGroup(tokenScopes, combinedScopes) { + if SatisfiesAnyGroup(tokenScopeSet, combinedScopes) { return nil } return BestScopeChallengeWithExisting(tokenScopes, combinedScopes, m.scopeChallengeIncludeTokenScopes) } -// validateScopesForRequest checks if the token contains all required scopes -func (m *MCPAuthMiddleware) validateScopesForRequest(claims authentication.Claims, requiredScopes []string) error { - // If no scopes are required, skip validation - if len(requiredScopes) == 0 { - return nil - } - - // Extract scopes from claims - tokenScopes := extractScopes(claims) - - // Check if all required scopes are present - var missingScopes []string - for _, requiredScope := range requiredScopes { - if !slices.Contains(tokenScopes, requiredScope) { - missingScopes = append(missingScopes, requiredScope) +// findMissing returns scopes from required that are not in tokenSet. +func findMissing(tokenSet map[string]struct{}, required []string) []string { + var missing []string + for _, s := range required { + if _, ok := tokenSet[s]; !ok { + missing = append(missing, s) } } - - if len(missingScopes) > 0 { - return fmt.Errorf("missing required scopes: %s", strings.Join(missingScopes, ", ")) - } - - return nil + return missing } -// extractScopes extracts scope values from JWT claims -// Supports only the OAuth 2.0 standard "scope" claim as a space-separated string +// extractScopes extracts space-separated scope values from the OAuth 2.0 "scope" claim. func extractScopes(claims authentication.Claims) []string { - // Check for "scope" claim (OAuth 2.0 standard - space-separated string) scopeClaim, ok := claims["scope"] if !ok { - return []string{} + return nil } - - // Only support string format per OAuth 2.0 spec scopeStr, ok := scopeClaim.(string) if !ok { - return []string{} + return nil } - - // Use Fields() to split on any whitespace (spaces, tabs, newlines) - // and automatically filter out empty strings return strings.Fields(scopeStr) } -// GetClaimsFromContext retrieves authenticated user claims from context +// GetClaimsFromContext retrieves authenticated user claims from context. func GetClaimsFromContext(ctx context.Context) (authentication.Claims, bool) { claims, ok := ctx.Value(userClaimsContextKey).(authentication.Claims) return claims, ok diff --git a/router/pkg/mcpserver/auth_middleware_test.go b/router/pkg/mcpserver/auth_middleware_test.go index 165628a047..97d818a7b6 100644 --- a/router/pkg/mcpserver/auth_middleware_test.go +++ b/router/pkg/mcpserver/auth_middleware_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/wundergraph/cosmo/router/pkg/authentication" + "github.com/wundergraph/cosmo/router/pkg/config" ) // mockTokenDecoder is a mock implementation of authentication.TokenDecoder for testing @@ -35,32 +36,23 @@ func TestNewMCPAuthMiddleware(t *testing.T) { tests := []struct { name string decoder authentication.TokenDecoder - enabled bool wantErr bool }{ { - name: "valid decoder and enabled", + name: "valid decoder", decoder: validDecoder, - enabled: true, - wantErr: false, - }, - { - name: "valid decoder and disabled", - decoder: validDecoder, - enabled: false, wantErr: false, }, { name: "nil decoder", decoder: nil, - enabled: true, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - middleware, err := NewMCPAuthMiddleware(tt.decoder, tt.enabled, "http://localhost:5025/.well-known/oauth-protected-resource/mcp", MCPScopeConfig{}, false) + middleware, err := NewMCPAuthMiddleware(tt.decoder, "http://localhost:5025/.well-known/oauth-protected-resource/mcp", config.MCPOAuthScopesConfiguration{}, false) if tt.wantErr { assert.Error(t, err) assert.Nil(t, middleware) @@ -139,7 +131,7 @@ func TestExtractScopes(t *testing.T) { { name: "no scope claim", claims: authentication.Claims{}, - want: []string{}, + want: nil, }, { name: "empty scope string", @@ -165,7 +157,7 @@ func TestMCPAuthMiddleware_HTTPMiddleware(t *testing.T) { tests := []struct { name string - scopes MCPScopeConfig + scopes config.MCPOAuthScopesConfiguration setupDecoder func() *mockTokenDecoder setupRequest func() *http.Request wantStatusCode int @@ -173,7 +165,7 @@ func TestMCPAuthMiddleware_HTTPMiddleware(t *testing.T) { }{ { name: "valid token without scopes", - scopes: MCPScopeConfig{}, + scopes: config.MCPOAuthScopesConfiguration{}, setupDecoder: func() *mockTokenDecoder { return &mockTokenDecoder{ decodeFunc: func(token string) (authentication.Claims, error) { @@ -193,7 +185,7 @@ func TestMCPAuthMiddleware_HTTPMiddleware(t *testing.T) { }, { name: "missing auth header - 401 includes init scopes", - scopes: MCPScopeConfig{Initialize: []string{"mcp:connect"}}, + scopes: config.MCPOAuthScopesConfiguration{Initialize: []string{"mcp:connect"}}, setupDecoder: func() *mockTokenDecoder { return &mockTokenDecoder{ decodeFunc: func(token string) (authentication.Claims, error) { @@ -210,7 +202,7 @@ func TestMCPAuthMiddleware_HTTPMiddleware(t *testing.T) { }, { name: "missing auth header - 401 without scopes when none configured", - scopes: MCPScopeConfig{}, + scopes: config.MCPOAuthScopesConfiguration{}, setupDecoder: func() *mockTokenDecoder { return &mockTokenDecoder{ decodeFunc: func(token string) (authentication.Claims, error) { @@ -227,7 +219,7 @@ func TestMCPAuthMiddleware_HTTPMiddleware(t *testing.T) { }, { name: "invalid token - 401 includes init scopes", - scopes: MCPScopeConfig{Initialize: []string{"mcp:connect"}}, + scopes: config.MCPOAuthScopesConfiguration{Initialize: []string{"mcp:connect"}}, setupDecoder: func() *mockTokenDecoder { return &mockTokenDecoder{ decodeFunc: func(token string) (authentication.Claims, error) { @@ -245,7 +237,7 @@ func TestMCPAuthMiddleware_HTTPMiddleware(t *testing.T) { }, { name: "insufficient init scopes - 403 with include token scopes enabled", - scopes: MCPScopeConfig{Initialize: []string{"mcp:connect"}}, + scopes: config.MCPOAuthScopesConfiguration{Initialize: []string{"mcp:connect"}}, setupDecoder: func() *mockTokenDecoder { return &mockTokenDecoder{ decodeFunc: func(token string) (authentication.Claims, error) { @@ -269,7 +261,7 @@ func TestMCPAuthMiddleware_HTTPMiddleware(t *testing.T) { }, { name: "valid token with all required scopes", - scopes: MCPScopeConfig{ + scopes: config.MCPOAuthScopesConfiguration{ Initialize: []string{"mcp:connect"}, }, setupDecoder: func() *mockTokenDecoder { @@ -297,7 +289,7 @@ func TestMCPAuthMiddleware_HTTPMiddleware(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { decoder := tt.setupDecoder() - middleware, err := NewMCPAuthMiddleware(decoder, true, testMetadataURL, tt.scopes, true) + middleware, err := NewMCPAuthMiddleware(decoder, testMetadataURL, tt.scopes, true) assert.NoError(t, err) handler := middleware.HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -341,7 +333,7 @@ func TestMCPAuthMiddleware_PerToolScopes(t *testing.T) { }, } - scopes := MCPScopeConfig{ + scopes := config.MCPOAuthScopesConfiguration{ Initialize: []string{"mcp:connect"}, ToolsCall: []string{"mcp:tools:write"}, } @@ -448,7 +440,7 @@ func TestMCPAuthMiddleware_PerToolScopes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - middleware, err := NewMCPAuthMiddleware(validDecoder, true, testMetadataURL, scopes, tt.scopeChallengeIncludeTokenScopes) + middleware, err := NewMCPAuthMiddleware(validDecoder, testMetadataURL, scopes, tt.scopeChallengeIncludeTokenScopes) assert.NoError(t, err) // Set per-tool scopes @@ -506,7 +498,7 @@ func TestMCPAuthMiddleware_MethodLevelScopes(t *testing.T) { }, } - scopes := MCPScopeConfig{ + scopes := config.MCPOAuthScopesConfiguration{ Initialize: []string{"mcp:connect"}, ToolsList: []string{"mcp:tools:read"}, ToolsCall: []string{"mcp:tools:write"}, @@ -577,7 +569,7 @@ func TestMCPAuthMiddleware_MethodLevelScopes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - middleware, err := NewMCPAuthMiddleware(validDecoder, true, testMetadataURL, scopes, tt.scopeChallengeIncludeTokenScopes) + middleware, err := NewMCPAuthMiddleware(validDecoder, testMetadataURL, scopes, tt.scopeChallengeIncludeTokenScopes) assert.NoError(t, err) handler := middleware.HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -621,7 +613,7 @@ func TestMCPAuthMiddleware_BuiltinToolScopes(t *testing.T) { }, } - scopes := MCPScopeConfig{ + scopes := config.MCPOAuthScopesConfiguration{ Initialize: []string{"mcp:connect"}, ToolsCall: []string{"mcp:tools:call"}, ExecuteGraphQL: []string{"mcp:graphql:execute"}, @@ -685,7 +677,7 @@ func TestMCPAuthMiddleware_BuiltinToolScopes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - middleware, err := NewMCPAuthMiddleware(validDecoder, true, testMetadataURL, scopes, false) + middleware, err := NewMCPAuthMiddleware(validDecoder, testMetadataURL, scopes, false) assert.NoError(t, err) handler := middleware.HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/router/pkg/mcpserver/execute_graphql_scope_test.go b/router/pkg/mcpserver/execute_graphql_scope_test.go index 3f8e75f865..f7b7c3895f 100644 --- a/router/pkg/mcpserver/execute_graphql_scope_test.go +++ b/router/pkg/mcpserver/execute_graphql_scope_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/wundergraph/cosmo/router/pkg/authentication" + "github.com/wundergraph/cosmo/router/pkg/config" ) func TestMCPAuthMiddleware_ExecuteGraphQLScopes(t *testing.T) { @@ -42,7 +43,7 @@ func TestMCPAuthMiddleware_ExecuteGraphQLScopes(t *testing.T) { }, } - scopes := MCPScopeConfig{ + scopes := config.MCPOAuthScopesConfiguration{ Initialize: []string{"mcp:connect"}, ToolsCall: []string{"mcp:tools:write"}, } @@ -139,7 +140,7 @@ func TestMCPAuthMiddleware_ExecuteGraphQLScopes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - middleware, err := NewMCPAuthMiddleware(validDecoder, true, testMetadataURL, scopes, tt.scopeChallengeIncludeTokenScopes) + middleware, err := NewMCPAuthMiddleware(validDecoder, testMetadataURL, scopes, tt.scopeChallengeIncludeTokenScopes) assert.NoError(t, err) // Set scope extractor for execute_graphql runtime checking @@ -179,12 +180,12 @@ func TestMCPAuthMiddleware_ExecuteGraphQLNoExtractor(t *testing.T) { }, } - scopes := MCPScopeConfig{ + scopes := config.MCPOAuthScopesConfiguration{ Initialize: []string{"mcp:connect"}, ToolsCall: []string{"mcp:tools:write"}, } - middleware, err := NewMCPAuthMiddleware(decoder, true, testMetadataURL, scopes, false) + middleware, err := NewMCPAuthMiddleware(decoder, testMetadataURL, scopes, false) assert.NoError(t, err) // Deliberately NOT setting a scope extractor diff --git a/router/pkg/mcpserver/operation_manager.go b/router/pkg/mcpserver/operation_manager.go index d1ab9833a3..8788225d54 100644 --- a/router/pkg/mcpserver/operation_manager.go +++ b/router/pkg/mcpserver/operation_manager.go @@ -92,9 +92,7 @@ func (om *OperationsManager) ComputeToolScopes(fieldConfigs []*nodev1.FieldConfi extractor := NewScopeExtractor(fieldConfigs, om.schemaDoc) for i := range om.operations { fieldReqs := extractor.ExtractScopesForOperation(&om.operations[i].Document) - if len(fieldReqs) > 0 { - om.operations[i].RequiredScopes = extractor.ComputeCombinedScopes(fieldReqs) - } + om.operations[i].RequiredScopes = extractor.ComputeCombinedScopes(fieldReqs) } } diff --git a/router/pkg/mcpserver/scope_challenge.go b/router/pkg/mcpserver/scope_challenge.go index 29d4eacf7d..e00a960dff 100644 --- a/router/pkg/mcpserver/scope_challenge.go +++ b/router/pkg/mcpserver/scope_challenge.go @@ -1,15 +1,13 @@ package mcpserver -import "slices" - -// SatisfiesAnyGroup checks whether tokenScopes satisfies at least one AND-group +// SatisfiesAnyGroup checks whether tokenScopeSet satisfies at least one AND-group // in the OR-of-AND scope requirements. Returns true if no requirements exist. -func SatisfiesAnyGroup(tokenScopes []string, orScopes [][]string) bool { +func SatisfiesAnyGroup(tokenScopeSet map[string]struct{}, orScopes [][]string) bool { if len(orScopes) == 0 { return true } for _, andGroup := range orScopes { - if satisfiesAll(tokenScopes, andGroup) { + if satisfiesAll(tokenScopeSet, andGroup) { return true } } @@ -23,8 +21,7 @@ func SatisfiesAnyGroup(tokenScopes []string, orScopes [][]string) bool { // Algorithm: // 1. For each AND-group, count how many scopes the token is missing. // 2. If any group has 0 missing, return nil (already satisfied). -// 3. Pick the group with the fewest missing scopes. -// 4. On ties, pick the first group (stable ordering). +// 3. Pick the group with the fewest missing scopes (ties: first group wins). func BestScopeChallenge(tokenScopes []string, combinedOrScopes [][]string) []string { if len(combinedOrScopes) == 0 { return nil @@ -68,30 +65,13 @@ func BestScopeChallengeWithExisting(tokenScopes []string, combinedOrScopes [][]s return best } - // Union: token scopes first, then any scopes from the best group not already present - seen := make(map[string]struct{}, len(tokenScopes)+len(best)) - result := make([]string, 0, len(tokenScopes)+len(best)) - - for _, s := range tokenScopes { - if _, ok := seen[s]; !ok { - seen[s] = struct{}{} - result = append(result, s) - } - } - for _, s := range best { - if _, ok := seen[s]; !ok { - seen[s] = struct{}{} - result = append(result, s) - } - } - - return result + return mergeAndDedup(tokenScopes, best) } -// satisfiesAll returns true if tokenScopes contains every scope in required. -func satisfiesAll(tokenScopes []string, required []string) bool { +// satisfiesAll returns true if tokenScopeSet contains every scope in required. +func satisfiesAll(tokenScopeSet map[string]struct{}, required []string) bool { for _, r := range required { - if !slices.Contains(tokenScopes, r) { + if _, ok := tokenScopeSet[r]; !ok { return false } } diff --git a/router/pkg/mcpserver/scope_challenge_test.go b/router/pkg/mcpserver/scope_challenge_test.go index ba2fa7347c..13d4b6c02c 100644 --- a/router/pkg/mcpserver/scope_challenge_test.go +++ b/router/pkg/mcpserver/scope_challenge_test.go @@ -332,7 +332,7 @@ func TestSatisfiesAnyGroup(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := SatisfiesAnyGroup(tt.tokenScopes, tt.orScopes) + got := SatisfiesAnyGroup(toSet(tt.tokenScopes), tt.orScopes) assert.Equal(t, tt.want, got) }) } diff --git a/router/pkg/mcpserver/server.go b/router/pkg/mcpserver/server.go index fb0b770132..087ccdeea8 100644 --- a/router/pkg/mcpserver/server.go +++ b/router/pkg/mcpserver/server.go @@ -91,6 +91,8 @@ type Options struct { OAuthConfig *config.MCPOAuthConfiguration // ServerBaseURL is the base URL of this MCP server (for resource metadata) ServerBaseURL string + // ResourceDocumentation is a URL to a human-readable page describing this resource + ResourceDocumentation string } // GraphQLSchemaServer represents an MCP server that works with GraphQL schemas and operations @@ -117,8 +119,8 @@ type GraphQLSchemaServer struct { cancel context.CancelFunc oauthConfig *config.MCPOAuthConfiguration serverBaseURL string + resourceDocumentation string authMiddleware *MCPAuthMiddleware - scopeExtractor *ScopeExtractor } type graphqlRequest struct { @@ -220,7 +222,6 @@ func NewGraphQLSchemaServer(routerGraphQLEndpoint string, opts ...func(*Options) // Create a cancellable context for managing the server lifecycle ctx, cancel := context.WithCancel(context.Background()) - // Add authentication middleware if OAuth is configured var authMiddleware *MCPAuthMiddleware if options.OAuthConfig != nil && options.OAuthConfig.Enabled { if len(options.OAuthConfig.JWKS) == 0 { @@ -268,28 +269,12 @@ func NewGraphQLSchemaServer(routerGraphQLEndpoint string, opts ...func(*Options) resourceMetadataURL = fmt.Sprintf("%s/.well-known/oauth-protected-resource/mcp", options.ServerBaseURL) } - // Create authentication middleware with scope configuration - // The middleware checks scopes at three levels: - // - initialize: scopes required for all HTTP requests - // - tools_list: scopes required for tools/list method - // - tools_call: scopes required for tools/call method (any tool) - scopeConfig := MCPScopeConfig{ - Initialize: options.OAuthConfig.Scopes.Initialize, - ToolsList: options.OAuthConfig.Scopes.ToolsList, - ToolsCall: options.OAuthConfig.Scopes.ToolsCall, - ExecuteGraphQL: options.OAuthConfig.Scopes.ExecuteGraphQL, - GetOperationInfo: options.OAuthConfig.Scopes.GetOperationInfo, - GetSchema: options.OAuthConfig.Scopes.GetSchema, - } - authMiddleware, err = NewMCPAuthMiddleware(tokenDecoder, true, resourceMetadataURL, scopeConfig, options.OAuthConfig.ScopeChallengeIncludeTokenScopes) + authMiddleware, err = NewMCPAuthMiddleware(tokenDecoder, resourceMetadataURL, options.OAuthConfig.Scopes, options.OAuthConfig.ScopeChallengeIncludeTokenScopes) if err != nil { cancel() // Clean up the context if initialization fails return nil, fmt.Errorf("failed to create auth middleware: %w", err) } - // Store auth middleware for HTTP-level protection - // Note: We don't use tool middleware here because per MCP spec, - // ALL HTTP requests must be authenticated, not just tool calls options.Logger.Info("MCP OAuth authentication enabled", zap.Int("jwks_providers", len(options.OAuthConfig.JWKS)), zap.String("authorization_server", options.OAuthConfig.AuthorizationServerURL)) @@ -335,6 +320,7 @@ func NewGraphQLSchemaServer(routerGraphQLEndpoint string, opts ...func(*Options) cancel: cancel, oauthConfig: options.OAuthConfig, serverBaseURL: options.ServerBaseURL, + resourceDocumentation: options.ResourceDocumentation, authMiddleware: authMiddleware, } @@ -436,6 +422,13 @@ func WithServerBaseURL(baseURL string) func(*Options) { } } +// WithResourceDocumentation sets the human-readable documentation URL for RFC 9728 metadata +func WithResourceDocumentation(url string) func(*Options) { + return func(o *Options) { + o.ResourceDocumentation = url + } +} + // Serve starts the server with the configured options and returns the HTTP server. func (s *GraphQLSchemaServer) Serve() (*http.Server, error) { // Create custom HTTP server @@ -460,32 +453,18 @@ func (s *GraphQLSchemaServer) Serve() (*http.Server, error) { mux := http.NewServeMux() - // OAuth 2.0 Protected Resource Metadata endpoint (RFC 9728 Section 3.1) - // This endpoint is required for MCP clients to discover the authorization server. - // This endpoint is NOT protected by authentication (it's public discovery). - // - // Per RFC 9728, when a resource is served at a path other than /, the well-known - // URI must include the path suffix: /.well-known/oauth-protected-resource/mcp + // OAuth 2.0 Protected Resource Metadata (RFC 9728) — public discovery endpoint if s.oauthConfig != nil && s.oauthConfig.Enabled && s.oauthConfig.AuthorizationServerURL != "" { mux.Handle("/.well-known/oauth-protected-resource/mcp", middleware(http.HandlerFunc(s.handleProtectedResourceMetadata))) - s.logger.Info("OAuth 2.0 Protected Resource Metadata endpoint enabled (RFC 9728 path-aware)", - zap.String("path", "/.well-known/oauth-protected-resource/mcp"), - zap.String("authorization_server", s.oauthConfig.AuthorizationServerURL)) } - // MCP endpoint with HTTP-level authentication - // Per MCP spec: "authorization MUST be included in every HTTP request from client to server" mcpHandler := http.Handler(streamableHTTPHandler) - - // Apply authentication middleware if OAuth is enabled if s.authMiddleware != nil { mux.Handle("/mcp", middleware(s.authMiddleware.HTTPMiddleware(mcpHandler))) - s.logger.Info("MCP endpoint protected with OAuth authentication at HTTP level") } else { mux.Handle("/mcp", middleware(mcpHandler)) } - // Set the handler for the custom HTTP server httpServer.Handler = mux logger := []zap.Field{ @@ -541,16 +520,14 @@ func (s *GraphQLSchemaServer) Reload(schema *ast.Document, fieldConfigs []*nodev } // Compute per-tool scope requirements from @requiresScopes directives + var scopeExtractor *ScopeExtractor if len(fieldConfigs) > 0 { s.operationsManager.ComputeToolScopes(fieldConfigs) - s.scopeExtractor = NewScopeExtractor(fieldConfigs, schema) - } else { - s.scopeExtractor = nil + scopeExtractor = NewScopeExtractor(fieldConfigs, schema) } - // Pass scope extractor to auth middleware for runtime execute_graphql scope checking if s.authMiddleware != nil { - s.authMiddleware.SetScopeExtractor(s.scopeExtractor) + s.authMiddleware.SetScopeExtractor(scopeExtractor) } s.server.RemoveTools(s.registeredTools...) @@ -724,7 +701,6 @@ func (s *GraphQLSchemaServer) registerTools() error { inputSchema = map[string]any{"type": "object", "properties": map[string]any{}} } - idempotent := op.OperationType != "mutation" openWorld := true tool := &mcp.Tool{ Name: toolName, @@ -738,9 +714,6 @@ func (s *GraphQLSchemaServer) registerTools() error { }, } - // IdempotentHint uses the plain bool value, but keep it for later if needed - _ = idempotent - s.server.AddTool(tool, s.handleOperation(handler)) s.registeredTools = append(s.registeredTools, toolName) @@ -1116,12 +1089,14 @@ func (s *GraphQLSchemaServer) handleProtectedResourceMetadata(w http.ResponseWri scopes = []string{} // Ensure non-nil for JSON encoding } + mcpResourceURL := strings.TrimRight(resourceURL, "/") + "/mcp" + metadata := ProtectedResourceMetadata{ - Resource: resourceURL, + Resource: mcpResourceURL, AuthorizationServers: []string{s.oauthConfig.AuthorizationServerURL}, BearerMethodsSupported: []string{"header"}, - ResourceDocumentation: fmt.Sprintf("%s/mcp", resourceURL), - ScopesSupported: scopes, // Automatically derived from required scopes + ResourceDocumentation: s.resourceDocumentation, + ScopesSupported: scopes, } // Encode to buffer first so we can handle errors before writing headers From 3877456aae6ee7c06ae609234f9f6815c43ef5bc Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 24 Mar 2026 21:23:50 +0000 Subject: [PATCH 05/46] fix(mcp): align go.mod dependencies with main branch --- router-tests/go.mod | 14 ++--- router-tests/go.sum | 28 +++++----- router-tests/mcp_oauth_e2e_test.go | 86 +++--------------------------- router/go.mod | 47 ++++++++-------- router/go.sum | 48 ++++++++--------- 5 files changed, 75 insertions(+), 148 deletions(-) diff --git a/router-tests/go.mod b/router-tests/go.mod index 0fc2f59d24..1455a2db9a 100644 --- a/router-tests/go.mod +++ b/router-tests/go.mod @@ -15,7 +15,7 @@ require ( github.com/hashicorp/go-retryablehttp v0.7.7 github.com/hasura/go-graphql-client v0.14.3 github.com/mark3labs/mcp-go v0.43.2 - github.com/modelcontextprotocol/go-sdk v1.4.0 + github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/nats-io/nats.go v1.35.0 github.com/prometheus/client_golang v1.19.1 github.com/prometheus/client_model v0.6.1 @@ -39,8 +39,8 @@ require ( go.uber.org/zap v1.27.0 golang.org/x/net v0.49.0 golang.org/x/sys v0.40.0 - google.golang.org/grpc v1.71.0 - google.golang.org/protobuf v1.36.9 + google.golang.org/grpc v1.79.3 + google.golang.org/protobuf v1.36.10 gopkg.in/yaml.v3 v3.0.1 ) @@ -136,7 +136,7 @@ require ( github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect github.com/segmentio/asm v1.1.3 // indirect - github.com/segmentio/encoding v0.5.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/sergi/go-diff v1.3.1 // indirect github.com/shirou/gopsutil/v3 v3.24.3 // indirect github.com/shoenig/go-m1cpu v0.1.7 // indirect @@ -169,7 +169,7 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.23.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 // indirect go.opentelemetry.io/otel/exporters/prometheus v0.50.0 // indirect - go.opentelemetry.io/otel/metric v1.36.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/proto/otlp v1.4.0 // indirect go.uber.org/automaxprocs v1.5.3 // indirect go.uber.org/multierr v1.11.0 // indirect @@ -183,8 +183,8 @@ require ( golang.org/x/text v0.33.0 // indirect golang.org/x/time v0.9.0 // indirect golang.org/x/tools v0.41.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect ) diff --git a/router-tests/go.sum b/router-tests/go.sum index 7059ffe5be..f78bdb43d3 100644 --- a/router-tests/go.sum +++ b/router-tests/go.sum @@ -230,8 +230,8 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8= -github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E= +github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= +github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nats-io/nats.go v1.35.0 h1:XFNqNM7v5B+MQMKqVGAyHwYhyKb48jrenXNxIU20ULk= @@ -293,8 +293,8 @@ github.com/sebdah/goldie/v2 v2.7.1 h1:PkBHymaYdtvEkZV7TmyqKxdmn5/Vcj+8TpATWZjnG5 github.com/sebdah/goldie/v2 v2.7.1/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= -github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= -github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= @@ -475,16 +475,16 @@ golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.14.0 h1:2NiG67LD1tEH0D7kM+ps2V+fXmsAnpUeec7n8tcr4S0= -gonum.org/v1/gonum v0.14.0/go.mod h1:AoWeoz0becf9QMWtE8iWXNXc27fK4fNeHNf/oMejGfU= -google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a h1:DMCgtIAIQGZqJXMVzJF4MV8BlWoJh2ZuFiRdAleyr58= -google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a/go.mod h1:y2yVLIE/CSMCPXaHnSKXxu1spLPnglFLegmgdY23uuE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a h1:tPE/Kp+x9dMSwUm/uM0JKK0IfdiJkwAbSMSeZBXXJXc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= -google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= -google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= -google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= -google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y= gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/router-tests/mcp_oauth_e2e_test.go b/router-tests/mcp_oauth_e2e_test.go index 2125b1fe9d..c682bec748 100644 --- a/router-tests/mcp_oauth_e2e_test.go +++ b/router-tests/mcp_oauth_e2e_test.go @@ -12,70 +12,6 @@ import ( "github.com/wundergraph/cosmo/router/pkg/config" ) -// TestMCPOAuthScopeUpgrade tests the complete OAuth scope upgrade flow with real JWT validation. -// Uses OAuthTestServer which provides a full OAuth 2.1 AS (JWKS + token endpoint + registration) -// so the same server can be used by both Go tests and the official MCP TypeScript SDK. -func TestMCPOAuthScopeUpgrade(t *testing.T) { - oauthServer, err := testutil.NewOAuthTestServer(t, nil) - require.NoError(t, err, "failed to start OAuth server") - defer oauthServer.Close() //nolint:errcheck - - readOnlyToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:tools:read"}) - require.NoError(t, err, "failed to create read-only token") - - testenv.Run(t, &testenv.Config{ - MCP: config.MCPConfiguration{ - Enabled: true, - ExposeSchema: true, - EnableArbitraryOperations: true, - OAuth: config.MCPOAuthConfiguration{ - Enabled: true, - JWKS: []config.JWKSConfiguration{ - {URL: oauthServer.JWKSURL()}, - }, - AuthorizationServerURL: oauthServer.Issuer(), - Scopes: config.MCPOAuthScopesConfiguration{}, - }, - }, - MCPAuthToken: readOnlyToken, - }, func(t *testing.T, xEnv *testenv.Environment) { - ctx := context.Background() - - client := NewMCPAuthClient(xEnv.GetMCPServerAddr(), readOnlyToken) - err = client.Connect(ctx) - require.NoError(t, err, "should connect with valid token") - defer client.Close() //nolint:errcheck - - t.Log("Connected with read-only token") - - result, err := client.CallTool(ctx, "get_schema", nil) - require.NoError(t, err, "get_schema should succeed with valid token") - require.NotNil(t, result) - t.Log("Tool call succeeded with initial token") - - newToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:tools:read", "mcp:tools:write"}) - require.NoError(t, err, "failed to create new token") - - client.SetToken(newToken) - t.Log("Updated to new token (same session)") - - result, err = client.CallTool(ctx, "execute_graphql", map[string]any{ - "query": "query { employees { id } }", - }) - require.NoError(t, err, "tool call should succeed after token change") - require.NotNil(t, result) - t.Log("Tool call succeeded with new token") - - anotherToken, err := oauthServer.CreateTokenWithScopes("different-user", []string{"mcp:admin"}) - require.NoError(t, err, "failed to create another token") - - client.SetToken(anotherToken) - _, err = client.CallTool(ctx, "get_schema", nil) - require.NoError(t, err, "should succeed after second token change") - t.Log("Multiple token changes work on same session") - }) -} - // TestMCPOAuthInvalidToken tests that invalid JWT tokens are rejected with HTTP 401. func TestMCPOAuthInvalidToken(t *testing.T) { oauthServer, err := testutil.NewOAuthTestServer(t, nil) @@ -106,11 +42,9 @@ func TestMCPOAuthInvalidToken(t *testing.T) { require.Error(t, err, "should fail to connect with invalid token") authErr, ok := err.(*AuthError) - if ok { - assert.Equal(t, http.StatusUnauthorized, authErr.StatusCode, "should return HTTP 401") - assert.NotEmpty(t, authErr.ResourceMetadataURL, "should include resource_metadata for OAuth discovery") - t.Logf("Invalid token rejected with HTTP 401: %v", authErr) - } + require.True(t, ok, "expected *AuthError but got %T: %v", err, err) + assert.Equal(t, http.StatusUnauthorized, authErr.StatusCode, "should return HTTP 401") + assert.NotEmpty(t, authErr.ResourceMetadataURL, "should include resource_metadata for OAuth discovery") }) } @@ -144,11 +78,9 @@ func TestMCPOAuthMissingToken(t *testing.T) { require.Error(t, err, "should fail to connect without token") authErr, ok := err.(*AuthError) - if ok { - assert.Equal(t, http.StatusUnauthorized, authErr.StatusCode, "should return HTTP 401") - assert.NotEmpty(t, authErr.ResourceMetadataURL, "should include resource_metadata for OAuth discovery") - t.Logf("Request without token rejected with HTTP 401: %v", authErr) - } + require.True(t, ok, "expected *AuthError but got %T: %v", err, err) + assert.Equal(t, http.StatusUnauthorized, authErr.StatusCode, "should return HTTP 401") + assert.NotEmpty(t, authErr.ResourceMetadataURL, "should include resource_metadata for OAuth discovery") }) } @@ -192,10 +124,8 @@ func TestMCPOAuthPerToolScopes(t *testing.T) { require.Error(t, err, "should fail to connect without HTTP-level scopes") authErr, ok := err.(*AuthError) - if ok { - assert.True(t, authErr.StatusCode == http.StatusUnauthorized || authErr.StatusCode == http.StatusForbidden) - t.Logf("HTTP-level scope enforcement: %v", authErr) - } + require.True(t, ok, "expected *AuthError but got %T: %v", err, err) + assert.True(t, authErr.StatusCode == http.StatusUnauthorized || authErr.StatusCode == http.StatusForbidden) }) t.Run("Per-tool scopes are enforced on tool calls", func(t *testing.T) { diff --git a/router/go.mod b/router/go.mod index c1d8abe25d..5643fc3a22 100644 --- a/router/go.mod +++ b/router/go.mod @@ -1,6 +1,6 @@ module github.com/wundergraph/cosmo/router -go 1.25.0 +go 1.25 require ( connectrpc.com/connect v1.16.2 @@ -13,7 +13,7 @@ require ( github.com/go-redis/redis_rate/v10 v10.0.1 github.com/gobwas/ws v1.4.0 github.com/goccy/go-yaml v1.17.1 - github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/gorilla/websocket v1.5.1 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-retryablehttp v0.7.7 @@ -36,24 +36,24 @@ require ( go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 go.opentelemetry.io/contrib/propagators/b3 v1.23.0 go.opentelemetry.io/contrib/propagators/jaeger v1.23.0 - go.opentelemetry.io/otel v1.36.0 + go.opentelemetry.io/otel v1.39.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.44.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.23.1 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 go.opentelemetry.io/otel/exporters/prometheus v0.50.0 - go.opentelemetry.io/otel/metric v1.36.0 - go.opentelemetry.io/otel/sdk v1.36.0 - go.opentelemetry.io/otel/sdk/metric v1.36.0 - go.opentelemetry.io/otel/trace v1.36.0 + go.opentelemetry.io/otel/metric v1.39.0 + go.opentelemetry.io/otel/sdk v1.39.0 + go.opentelemetry.io/otel/sdk/metric v1.39.0 + go.opentelemetry.io/otel/trace v1.39.0 go.uber.org/atomic v1.11.0 go.uber.org/automaxprocs v1.5.3 go.uber.org/zap v1.27.0 go.withmatt.com/connect-brotli v0.4.0 - golang.org/x/sync v0.17.0 - golang.org/x/sys v0.41.0 // indirect - google.golang.org/grpc v1.71.0 - google.golang.org/protobuf v1.36.9 + golang.org/x/sync v0.19.0 + golang.org/x/sys v0.40.0 // indirect + google.golang.org/grpc v1.79.3 + google.golang.org/protobuf v1.36.10 ) require ( @@ -76,6 +76,7 @@ require ( github.com/iancoleman/strcase v0.3.0 github.com/klauspost/compress v1.18.0 github.com/minio/minio-go/v7 v7.0.74 + github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/posthog/posthog-go v1.5.5 github.com/pquerna/cachecontrol v0.2.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 @@ -85,19 +86,11 @@ require ( go.uber.org/goleak v1.3.0 go.uber.org/ratelimit v0.3.1 golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 - golang.org/x/net v0.46.0 - golang.org/x/text v0.30.0 + golang.org/x/net v0.48.0 + golang.org/x/text v0.32.0 golang.org/x/time v0.9.0 ) -require ( - github.com/frankban/quicktest v1.14.6 // indirect - github.com/google/jsonschema-go v0.4.2 // indirect - github.com/segmentio/asm v1.1.3 // indirect - github.com/segmentio/encoding v0.5.4 // indirect - golang.org/x/oauth2 v0.35.0 // indirect -) - require ( github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // indirect github.com/benbjohnson/clock v1.3.0 // indirect @@ -115,6 +108,7 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/frankban/quicktest v1.14.6 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -123,6 +117,7 @@ require ( github.com/gobwas/pool v0.2.1 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang/protobuf v1.5.4 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -138,7 +133,6 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect - github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nats-io/nkeys v0.4.7 // indirect github.com/oklog/run v1.0.0 // indirect @@ -155,6 +149,8 @@ require ( github.com/prometheus/procfs v0.15.1 // indirect github.com/r3labs/sse/v2 v2.8.1 // indirect github.com/rs/xid v1.5.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/sergi/go-diff v1.3.1 // indirect github.com/shoenig/go-m1cpu v0.1.7 // indirect github.com/sirupsen/logrus v1.9.3 // indirect @@ -171,9 +167,10 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect go.opentelemetry.io/proto/otlp v1.4.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.43.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/router/go.sum b/router/go.sum index a084404994..0fac3444d5 100644 --- a/router/go.sum +++ b/router/go.sum @@ -110,8 +110,8 @@ github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= -github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -385,8 +385,8 @@ go.withmatt.com/connect-brotli v0.4.0 h1:7ObWkYmEbUXK3EKglD0Lgj0BBnnD3jNdAxeDRct go.withmatt.com/connect-brotli v0.4.0/go.mod h1:c2eELz56za+/Mxh1yJrlglZ4VM9krpOCPqS2Vxf8NVk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -395,13 +395,13 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -418,11 +418,11 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= @@ -433,16 +433,16 @@ golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.14.0 h1:2NiG67LD1tEH0D7kM+ps2V+fXmsAnpUeec7n8tcr4S0= -gonum.org/v1/gonum v0.14.0/go.mod h1:AoWeoz0becf9QMWtE8iWXNXc27fK4fNeHNf/oMejGfU= -google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a h1:DMCgtIAIQGZqJXMVzJF4MV8BlWoJh2ZuFiRdAleyr58= -google.golang.org/genproto/googleapis/api v0.0.0-20250811230008-5f3141c8851a/go.mod h1:y2yVLIE/CSMCPXaHnSKXxu1spLPnglFLegmgdY23uuE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a h1:tPE/Kp+x9dMSwUm/uM0JKK0IfdiJkwAbSMSeZBXXJXc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= -google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= -google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= -google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= -google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y= gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From df167f7cd428fdeaf4ef9fea4e85e73c38c5881e Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 31 Mar 2026 07:40:18 +0100 Subject: [PATCH 06/46] fix(mcp): cap scope combinations to prevent unbounded growth --- router/pkg/mcpserver/auth_middleware.go | 8 ++++- router/pkg/mcpserver/operation_manager.go | 11 ++++-- router/pkg/mcpserver/scope_extractor.go | 38 +++++++++++++++----- router/pkg/mcpserver/scope_extractor_test.go | 27 +++++++++++++- router/pkg/mcpserver/server.go | 4 ++- 5 files changed, 74 insertions(+), 14 deletions(-) diff --git a/router/pkg/mcpserver/auth_middleware.go b/router/pkg/mcpserver/auth_middleware.go index e23b25f1b6..469b4fe1af 100644 --- a/router/pkg/mcpserver/auth_middleware.go +++ b/router/pkg/mcpserver/auth_middleware.go @@ -283,7 +283,13 @@ func (m *MCPAuthMiddleware) checkExecuteGraphQLScopes(tokenScopes []string, toke return nil } - combinedScopes := extractor.ComputeCombinedScopes(fieldReqs) + combinedScopes, err := extractor.ComputeCombinedScopes(fieldReqs) + if err != nil { + // Scope combination limit exceeded — fail closed. The query touches too many + // @requiresScopes fields, producing a pathological number of combinations. + // Return a sentinel challenge so the caller sends 403. + return []string{"insufficient_scope"} + } if len(combinedScopes) == 0 { return nil } diff --git a/router/pkg/mcpserver/operation_manager.go b/router/pkg/mcpserver/operation_manager.go index 8788225d54..adb1c6d800 100644 --- a/router/pkg/mcpserver/operation_manager.go +++ b/router/pkg/mcpserver/operation_manager.go @@ -88,12 +88,19 @@ func (om *OperationsManager) GetOperation(name string) *schemaloader.Operation { // ComputeToolScopes runs the scope extractor against all loaded operations, // populating each operation's RequiredScopes from @requiresScopes directives. -func (om *OperationsManager) ComputeToolScopes(fieldConfigs []*nodev1.FieldConfiguration) { +// Returns an error if any operation exceeds the scope combination limit, which +// indicates a pathological @requiresScopes configuration that should be simplified. +func (om *OperationsManager) ComputeToolScopes(fieldConfigs []*nodev1.FieldConfiguration) error { extractor := NewScopeExtractor(fieldConfigs, om.schemaDoc) for i := range om.operations { fieldReqs := extractor.ExtractScopesForOperation(&om.operations[i].Document) - om.operations[i].RequiredScopes = extractor.ComputeCombinedScopes(fieldReqs) + combinedScopes, err := extractor.ComputeCombinedScopes(fieldReqs) + if err != nil { + return fmt.Errorf("tool %q: %w", om.operations[i].Name, err) + } + om.operations[i].RequiredScopes = combinedScopes } + return nil } // GetSchema returns the schema document used by the operations manager diff --git a/router/pkg/mcpserver/scope_extractor.go b/router/pkg/mcpserver/scope_extractor.go index 70ba02d721..7e4964ce8f 100644 --- a/router/pkg/mcpserver/scope_extractor.go +++ b/router/pkg/mcpserver/scope_extractor.go @@ -1,6 +1,8 @@ package mcpserver import ( + "fmt" + nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" @@ -8,6 +10,14 @@ import ( "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" ) +// MaxScopeCombinations is the hard cap on the number of OR-group combinations +// produced by the Cartesian product across scoped fields. This prevents +// pathological scope configurations (e.g., many fields each with multiple +// OR-groups in arbitrary execute_graphql operations) from consuming unbounded +// CPU/memory. 2048 provides generous headroom for enterprise RBAC while +// blocking exponential blowup. +const MaxScopeCombinations = 2048 + // FieldScopeRequirement represents the scope requirement for a single field. // OrScopes is a list of AND-groups — satisfy any one group to access the field. // e.g., [["a", "b"], ["c"]] means (a AND b) OR (c) @@ -71,11 +81,11 @@ func (e *ScopeExtractor) ExtractScopesForOperation(operation *ast.Document) []Fi // ComputeCombinedScopes computes the Cartesian product of OR-groups across fields, // deduplicating scopes within each combined AND-group. -// The product is unbounded at runtime because the composition layer already enforces -// MAX_OR_SCOPES = 16 per field, capping the scope groups that reach the router config. -func (e *ScopeExtractor) ComputeCombinedScopes(fieldReqs []FieldScopeRequirement) [][]string { +// Returns an error if the number of combinations exceeds MaxScopeCombinations, +// which prevents pathological scope configurations from consuming unbounded resources. +func (e *ScopeExtractor) ComputeCombinedScopes(fieldReqs []FieldScopeRequirement) ([][]string, error) { if len(fieldReqs) == 0 { - return nil + return nil, nil } // Start with the first field's OR-groups @@ -83,10 +93,15 @@ func (e *ScopeExtractor) ComputeCombinedScopes(fieldReqs []FieldScopeRequirement // Iteratively cross-product with each subsequent field's OR-groups for i := 1; i < len(fieldReqs); i++ { - result = crossProduct(result, fieldReqs[i].OrScopes) + product, err := crossProduct(result, fieldReqs[i].OrScopes) + if err != nil { + return nil, fmt.Errorf("scope combination limit (%d) exceeded at field %s.%s: %w", + MaxScopeCombinations, fieldReqs[i].TypeName, fieldReqs[i].FieldName, err) + } + result = product } - return result + return result, nil } // scopeFieldVisitor collects scoped field coordinates during AST walking. @@ -128,15 +143,20 @@ func (v *scopeFieldVisitor) EnterField(ref int) { // crossProduct computes the Cartesian product of two sets of OR-groups, // merging AND-scopes within each combination and deduplicating. -func crossProduct(a, b [][]string) [][]string { - result := make([][]string, 0, len(a)*len(b)) +// Returns an error if the resulting number of combinations would exceed MaxScopeCombinations. +func crossProduct(a, b [][]string) ([][]string, error) { + total := len(a) * len(b) + if total > MaxScopeCombinations { + return nil, fmt.Errorf("cross product would produce %d combinations", total) + } + result := make([][]string, 0, total) for _, groupA := range a { for _, groupB := range b { merged := mergeAndDedup(groupA, groupB) result = append(result, merged) } } - return result + return result, nil } // mergeAndDedup merges two AND-groups into one, preserving order and removing duplicates. diff --git a/router/pkg/mcpserver/scope_extractor_test.go b/router/pkg/mcpserver/scope_extractor_test.go index 7cec93eeec..58f3b4ba54 100644 --- a/router/pkg/mcpserver/scope_extractor_test.go +++ b/router/pkg/mcpserver/scope_extractor_test.go @@ -1,6 +1,7 @@ package mcpserver import ( + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -452,8 +453,32 @@ func TestComputeCombinedScopes(t *testing.T) { schemaDoc := parseTestSchema(t) extractor := NewScopeExtractor(testFieldConfigs(), &schemaDoc) - got := extractor.ComputeCombinedScopes(tt.fieldReqs) + got, err := extractor.ComputeCombinedScopes(tt.fieldReqs) + assert.NoError(t, err) assert.Equal(t, tt.want, got) }) } } + +func TestComputeCombinedScopes_ExceedsLimit(t *testing.T) { + t.Parallel() + + schemaDoc := parseTestSchema(t) + extractor := NewScopeExtractor(testFieldConfigs(), &schemaDoc) + + // Build field requirements that will exceed MaxScopeCombinations (2048). + // 12 fields × 2 OR-groups each = 2^12 = 4096 combinations > 2048. + fieldReqs := make([]FieldScopeRequirement, 12) + for i := range fieldReqs { + fieldReqs[i] = FieldScopeRequirement{ + TypeName: "Query", + FieldName: fmt.Sprintf("field_%d", i), + OrScopes: [][]string{{"scope_a"}, {"scope_b"}}, + } + } + + got, err := extractor.ComputeCombinedScopes(fieldReqs) + assert.Error(t, err) + assert.Nil(t, got) + assert.Contains(t, err.Error(), "scope combination limit") +} diff --git a/router/pkg/mcpserver/server.go b/router/pkg/mcpserver/server.go index 087ccdeea8..199083e130 100644 --- a/router/pkg/mcpserver/server.go +++ b/router/pkg/mcpserver/server.go @@ -522,7 +522,9 @@ func (s *GraphQLSchemaServer) Reload(schema *ast.Document, fieldConfigs []*nodev // Compute per-tool scope requirements from @requiresScopes directives var scopeExtractor *ScopeExtractor if len(fieldConfigs) > 0 { - s.operationsManager.ComputeToolScopes(fieldConfigs) + if err := s.operationsManager.ComputeToolScopes(fieldConfigs); err != nil { + return fmt.Errorf("failed to compute tool scopes: %w", err) + } scopeExtractor = NewScopeExtractor(fieldConfigs, schema) } From 8f2b53d2fe6e615f9a38f46d1f0972c1104cdb33 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 31 Mar 2026 08:03:50 +0100 Subject: [PATCH 07/46] fix(mcp): add language tags to code blocks --- router/pkg/mcpserver/scope_challenge.md | 43 +++++++++++++------------ 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/router/pkg/mcpserver/scope_challenge.md b/router/pkg/mcpserver/scope_challenge.md index 83bf410a42..ece7aafc91 100644 --- a/router/pkg/mcpserver/scope_challenge.md +++ b/router/pkg/mcpserver/scope_challenge.md @@ -10,7 +10,7 @@ The `@requiresScopes` directive uses **OR-of-AND** semantics — there may be mu Scopes are represented as `[][]string` — a list of AND-groups where satisfying **any one** group grants access: -``` +```text [["a", "b"], ["c", "d"]] → (a AND b) OR (c AND d) ``` @@ -19,6 +19,7 @@ When an operation touches multiple scoped fields, their requirements are combine ## Algorithm: `BestScopeChallenge` **Input:** + - `tokenScopes` — scopes the client's JWT currently has - `combinedOrScopes` — the tool's OR-of-AND requirements @@ -39,32 +40,32 @@ OAuth authorization requests specify the full set of scopes desired. The client ### Simple OR (single-scope groups) -``` +```text Required: [["read:fact"], ["read:all"]] ``` -| Token scopes | Missing per group | Best group | Challenge | -|---|---|---|---| -| `["read:fact"]` | 0, 1 | satisfied | `nil` | -| `["read:all"]` | 1, 0 | satisfied | `nil` | -| `[]` | 1, 1 | first (tie) | `["read:fact"]` | -| `["other"]` | 1, 1 | first (tie) | `["read:fact"]` | +| Token scopes | Missing per group | Best group | Challenge | +| --------------- | ----------------- | ----------- | --------------- | +| `["read:fact"]` | 0, 1 | satisfied | `nil` | +| `["read:all"]` | 1, 0 | satisfied | `nil` | +| `[]` | 1, 1 | first (tie) | `["read:fact"]` | +| `["other"]` | 1, 1 | first (tie) | `["read:fact"]` | ### AND group with shortcut -``` +```text Required: [["read:employee", "read:private"], ["read:all"]] ``` -| Token scopes | Missing per group | Best group | Challenge | -|---|---|---|---| -| `["read:employee", "read:private"]` | 0, 1 | satisfied | `nil` | -| `["read:employee"]` | 1, 1 | first (tie) | `["read:employee", "read:private"]` | -| `[]` | 2, 1 | group 2 | `["read:all"]` | +| Token scopes | Missing per group | Best group | Challenge | +| ----------------------------------- | ----------------- | ----------- | ----------------------------------- | +| `["read:employee", "read:private"]` | 0, 1 | satisfied | `nil` | +| `["read:employee"]` | 1, 1 | first (tie) | `["read:employee", "read:private"]` | +| `[]` | 2, 1 | group 2 | `["read:all"]` | ### Cross-subgraph aggregation -``` +```text Required: [ ["read:fact", "read:employee", "read:private"], ["read:fact", "read:all"], @@ -73,11 +74,11 @@ Required: [ ] ``` -| Token scopes | Missing per group | Best group | Challenge | -|---|---|---|---| -| `["read:all"]` | 2, 1, 2, 0 | satisfied | `nil` | -| `["read:fact"]` | 2, 1, 2, 1 | group 2 (tie→first) | `["read:fact", "read:all"]` | -| `[]` | 3, 2, 3, 1 | group 4 | `["read:all"]` | +| Token scopes | Missing per group | Best group | Challenge | +| --------------- | ----------------- | ------------------- | --------------------------- | +| `["read:all"]` | 2, 1, 2, 0 | satisfied | `nil` | +| `["read:fact"]` | 2, 1, 2, 1 | group 2 (tie→first) | `["read:fact", "read:all"]` | +| `[]` | 3, 2, 3, 1 | group 4 | `["read:all"]` | ## `BestScopeChallengeWithExisting` @@ -90,4 +91,4 @@ Example: token has `["init", "mcp:tools:write", "a"]`, best group is `["a", "b", ## `SatisfiesAnyGroup` -A simple check: does the token satisfy at least one AND-group? Returns `true` if requirements are empty/nil (no scopes needed). Used as the gate check before computing a challenge. \ No newline at end of file +A simple check: does the token satisfy at least one AND-group? Returns `true` if requirements are empty/nil (no scopes needed). Used as the gate check before computing a challenge. From 35591082d1879299bba292cb6ea8fa57d0973ed9 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 31 Mar 2026 08:14:04 +0100 Subject: [PATCH 08/46] fix(mcp): inject request headers into context for tool handlers --- router/pkg/mcpserver/server.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/router/pkg/mcpserver/server.go b/router/pkg/mcpserver/server.go index 199083e130..195417a04e 100644 --- a/router/pkg/mcpserver/server.go +++ b/router/pkg/mcpserver/server.go @@ -443,7 +443,6 @@ func (s *GraphQLSchemaServer) Serve() (*http.Server, error) { // The getServer function returns our MCP server instance for each request streamableHTTPHandler := mcp.NewStreamableHTTPHandler( func(req *http.Request) *mcp.Server { - // Add request headers to context for tool handlers return s.server }, nil, // Use default options @@ -458,7 +457,12 @@ func (s *GraphQLSchemaServer) Serve() (*http.Server, error) { mux.Handle("/.well-known/oauth-protected-resource/mcp", middleware(http.HandlerFunc(s.handleProtectedResourceMetadata))) } - mcpHandler := http.Handler(streamableHTTPHandler) + // Inject request headers into context so tool handlers can forward them + // to the GraphQL engine via headersFromContext. + mcpHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r = r.WithContext(requestHeadersFromRequest(r.Context(), r)) + streamableHTTPHandler.ServeHTTP(w, r) + }) if s.authMiddleware != nil { mux.Handle("/mcp", middleware(s.authMiddleware.HTTPMiddleware(mcpHandler))) } else { From c9f3cced5a6d0495ac60df9182ca63308a97f583 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 31 Mar 2026 08:38:09 +0100 Subject: [PATCH 09/46] fix(mcp): surface auth errors from Connect for OAuth discovery flow --- router-tests/mcp_auth_e2e_test.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/router-tests/mcp_auth_e2e_test.go b/router-tests/mcp_auth_e2e_test.go index 690619b422..cdb3924ed8 100644 --- a/router-tests/mcp_auth_e2e_test.go +++ b/router-tests/mcp_auth_e2e_test.go @@ -87,10 +87,16 @@ func NewMCPAuthClient(endpoint string, initialToken string) *MCPAuthClient { } } -// Connect establishes the MCP connection and initializes the session +// Connect establishes the MCP connection and initializes the session. +// Returns *AuthError if the server responds with 401/403, surfacing the +// parsed WWW-Authenticate header so callers can assert OAuth discovery +// and scope challenge behavior per the MCP authorization spec. func (c *MCPAuthClient) Connect(ctx context.Context) error { session, err := c.client.Connect(ctx, c.transport, nil) if err != nil { + if authErr := c.checkAuthError(); authErr != nil { + return authErr + } return fmt.Errorf("failed to connect: %w", err) } c.session = session @@ -164,4 +170,4 @@ func (c *MCPAuthClient) Close() error { return c.session.Close() } return nil -} \ No newline at end of file +} From 290ff870121f239bc929ebbbe9d371da34111b15 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 31 Mar 2026 08:50:10 +0100 Subject: [PATCH 10/46] fix: run go mod tidy for router --- router/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/router/go.mod b/router/go.mod index 5643fc3a22..9cb9443358 100644 --- a/router/go.mod +++ b/router/go.mod @@ -1,6 +1,6 @@ module github.com/wundergraph/cosmo/router -go 1.25 +go 1.25.0 require ( connectrpc.com/connect v1.16.2 From 325cc4b62baed0e66ba6b4ef863c786ae6938b56 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 31 Mar 2026 08:56:10 +0100 Subject: [PATCH 11/46] fix: run go mod tidy for router-tests --- router-tests/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/router-tests/go.mod b/router-tests/go.mod index 1455a2db9a..161a9ca13f 100644 --- a/router-tests/go.mod +++ b/router-tests/go.mod @@ -1,6 +1,6 @@ module github.com/wundergraph/cosmo/router-tests -go 1.25 +go 1.25.0 require ( connectrpc.com/connect v1.19.1 From 39fc0bb738bc39fcf0a040f4de74c4199b2833c4 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 31 Mar 2026 12:17:31 +0100 Subject: [PATCH 12/46] feat(mcp): make max scope combinations configurable --- router/pkg/config/config.go | 28 +++++++------ .../mcpserver/execute_graphql_scope_test.go | 2 +- router/pkg/mcpserver/operation_manager.go | 4 +- router/pkg/mcpserver/scope_extractor.go | 40 ++++++++++--------- router/pkg/mcpserver/scope_extractor_test.go | 8 ++-- router/pkg/mcpserver/server.go | 8 +++- 6 files changed, 51 insertions(+), 39 deletions(-) diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index c4b728de2f..9dafdbb877 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -1112,22 +1112,22 @@ type CacheWarmupConfiguration struct { } type MCPConfiguration struct { - Enabled bool `yaml:"enabled" envDefault:"false" env:"MCP_ENABLED"` - Server MCPServer `yaml:"server,omitempty"` - Storage MCPStorageConfig `yaml:"storage,omitempty"` - Session MCPSessionConfig `yaml:"session,omitempty"` - GraphName string `yaml:"graph_name" envDefault:"mygraph" env:"MCP_GRAPH_NAME"` - ExcludeMutations bool `yaml:"exclude_mutations" envDefault:"false" env:"MCP_EXCLUDE_MUTATIONS"` - EnableArbitraryOperations bool `yaml:"enable_arbitrary_operations" envDefault:"false" env:"MCP_ENABLE_ARBITRARY_OPERATIONS"` - ExposeSchema bool `yaml:"expose_schema" envDefault:"false" env:"MCP_EXPOSE_SCHEMA"` - RouterURL string `yaml:"router_url,omitempty" env:"MCP_ROUTER_URL"` + Enabled bool `yaml:"enabled" envDefault:"false" env:"MCP_ENABLED"` + Server MCPServer `yaml:"server,omitempty"` + Storage MCPStorageConfig `yaml:"storage,omitempty"` + Session MCPSessionConfig `yaml:"session,omitempty"` + GraphName string `yaml:"graph_name" envDefault:"mygraph" env:"MCP_GRAPH_NAME"` + ExcludeMutations bool `yaml:"exclude_mutations" envDefault:"false" env:"MCP_EXCLUDE_MUTATIONS"` + EnableArbitraryOperations bool `yaml:"enable_arbitrary_operations" envDefault:"false" env:"MCP_ENABLE_ARBITRARY_OPERATIONS"` + ExposeSchema bool `yaml:"expose_schema" envDefault:"false" env:"MCP_EXPOSE_SCHEMA"` + RouterURL string `yaml:"router_url,omitempty" env:"MCP_ROUTER_URL"` // OmitToolNamePrefix removes the "execute_operation_" prefix from MCP tool names. // When enabled, GetUser becomes get_user. When disabled (default), GetUser becomes execute_operation_get_user. - OmitToolNamePrefix bool `yaml:"omit_tool_name_prefix" envDefault:"false" env:"MCP_OMIT_TOOL_NAME_PREFIX"` - OAuth MCPOAuthConfiguration `yaml:"oauth,omitempty" envPrefix:"MCP_OAUTH_"` + OmitToolNamePrefix bool `yaml:"omit_tool_name_prefix" envDefault:"false" env:"MCP_OMIT_TOOL_NAME_PREFIX"` + OAuth MCPOAuthConfiguration `yaml:"oauth,omitempty" envPrefix:"MCP_OAUTH_"` // ResourceDocumentation is a URL to a human-readable page describing this MCP resource, // its access policies, and how to get started. Included in RFC 9728 Protected Resource Metadata if set. - ResourceDocumentation string `yaml:"resource_documentation,omitempty" env:"MCP_RESOURCE_DOCUMENTATION"` + ResourceDocumentation string `yaml:"resource_documentation,omitempty" env:"MCP_RESOURCE_DOCUMENTATION"` } type MCPOAuthConfiguration struct { @@ -1142,6 +1142,10 @@ type MCPOAuthConfiguration struct { // When true, the token's existing scopes are unioned with the required scopes. // This is a workaround for MCP client SDKs that replace rather than accumulate scopes. ScopeChallengeIncludeTokenScopes bool `yaml:"scope_challenge_include_token_scopes" envDefault:"false" env:"SCOPE_CHALLENGE_INCLUDE_TOKEN_SCOPES"` + // MaxScopeCombinations sets the upper limit on the number of OR-group combinations + // produced when computing the Cartesian product of @requiresScopes across fields. + // Defaults to 2048 if not set. Increase for complex RBAC configurations. + MaxScopeCombinations int `yaml:"max_scope_combinations" envDefault:"0" env:"MAX_SCOPE_COMBINATIONS"` } // MCPOAuthScopesConfiguration defines which scopes are required for different MCP operations. diff --git a/router/pkg/mcpserver/execute_graphql_scope_test.go b/router/pkg/mcpserver/execute_graphql_scope_test.go index f7b7c3895f..574bbd6591 100644 --- a/router/pkg/mcpserver/execute_graphql_scope_test.go +++ b/router/pkg/mcpserver/execute_graphql_scope_test.go @@ -20,7 +20,7 @@ func TestMCPAuthMiddleware_ExecuteGraphQLScopes(t *testing.T) { schema := parseTestSchema(t) fieldConfigs := testFieldConfigs() - extractor := NewScopeExtractor(fieldConfigs, &schema) + extractor := NewScopeExtractor(fieldConfigs, &schema, 0) validDecoder := &mockTokenDecoder{ decodeFunc: func(token string) (authentication.Claims, error) { diff --git a/router/pkg/mcpserver/operation_manager.go b/router/pkg/mcpserver/operation_manager.go index adb1c6d800..ea986bc5b9 100644 --- a/router/pkg/mcpserver/operation_manager.go +++ b/router/pkg/mcpserver/operation_manager.go @@ -90,8 +90,8 @@ func (om *OperationsManager) GetOperation(name string) *schemaloader.Operation { // populating each operation's RequiredScopes from @requiresScopes directives. // Returns an error if any operation exceeds the scope combination limit, which // indicates a pathological @requiresScopes configuration that should be simplified. -func (om *OperationsManager) ComputeToolScopes(fieldConfigs []*nodev1.FieldConfiguration) error { - extractor := NewScopeExtractor(fieldConfigs, om.schemaDoc) +func (om *OperationsManager) ComputeToolScopes(fieldConfigs []*nodev1.FieldConfiguration, maxScopeCombinations int) error { + extractor := NewScopeExtractor(fieldConfigs, om.schemaDoc, maxScopeCombinations) for i := range om.operations { fieldReqs := extractor.ExtractScopesForOperation(&om.operations[i].Document) combinedScopes, err := extractor.ComputeCombinedScopes(fieldReqs) diff --git a/router/pkg/mcpserver/scope_extractor.go b/router/pkg/mcpserver/scope_extractor.go index 7e4964ce8f..552e41283c 100644 --- a/router/pkg/mcpserver/scope_extractor.go +++ b/router/pkg/mcpserver/scope_extractor.go @@ -10,13 +10,11 @@ import ( "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" ) -// MaxScopeCombinations is the hard cap on the number of OR-group combinations -// produced by the Cartesian product across scoped fields. This prevents -// pathological scope configurations (e.g., many fields each with multiple -// OR-groups in arbitrary execute_graphql operations) from consuming unbounded -// CPU/memory. 2048 provides generous headroom for enterprise RBAC while -// blocking exponential blowup. -const MaxScopeCombinations = 2048 +// DefaultMaxScopeCombinations is the default cap on the number of OR-group +// combinations produced by the Cartesian product across scoped fields. +// This can be overridden via the mcp.oauth.scopes.max_scope_combinations +// config option for enterprises with complex RBAC configurations. +const DefaultMaxScopeCombinations = 2048 // FieldScopeRequirement represents the scope requirement for a single field. // OrScopes is a list of AND-groups — satisfy any one group to access the field. @@ -31,12 +29,17 @@ type FieldScopeRequirement struct { // from FieldConfigurations. type ScopeExtractor struct { // scopeIndex maps "TypeName.FieldName" to OR-of-AND scope groups for O(1) lookup. - scopeIndex map[string][][]string - schemaDoc *ast.Document + scopeIndex map[string][][]string + schemaDoc *ast.Document + maxScopeCombinations int } -// NewScopeExtractor creates a new ScopeExtractor. -func NewScopeExtractor(fieldConfigs []*nodev1.FieldConfiguration, schemaDoc *ast.Document) *ScopeExtractor { +// NewScopeExtractor creates a new ScopeExtractor. If maxScopeCombinations is 0, +// DefaultMaxScopeCombinations is used. +func NewScopeExtractor(fieldConfigs []*nodev1.FieldConfiguration, schemaDoc *ast.Document, maxScopeCombinations int) *ScopeExtractor { + if maxScopeCombinations <= 0 { + maxScopeCombinations = DefaultMaxScopeCombinations + } index := make(map[string][][]string) for _, fc := range fieldConfigs { authConfig := fc.GetAuthorizationConfiguration() @@ -54,8 +57,9 @@ func NewScopeExtractor(fieldConfigs []*nodev1.FieldConfiguration, schemaDoc *ast index[fc.GetTypeName()+"."+fc.GetFieldName()] = groups } return &ScopeExtractor{ - scopeIndex: index, - schemaDoc: schemaDoc, + scopeIndex: index, + schemaDoc: schemaDoc, + maxScopeCombinations: maxScopeCombinations, } } @@ -93,10 +97,10 @@ func (e *ScopeExtractor) ComputeCombinedScopes(fieldReqs []FieldScopeRequirement // Iteratively cross-product with each subsequent field's OR-groups for i := 1; i < len(fieldReqs); i++ { - product, err := crossProduct(result, fieldReqs[i].OrScopes) + product, err := crossProduct(result, fieldReqs[i].OrScopes, e.maxScopeCombinations) if err != nil { return nil, fmt.Errorf("scope combination limit (%d) exceeded at field %s.%s: %w", - MaxScopeCombinations, fieldReqs[i].TypeName, fieldReqs[i].FieldName, err) + e.maxScopeCombinations, fieldReqs[i].TypeName, fieldReqs[i].FieldName, err) } result = product } @@ -143,10 +147,10 @@ func (v *scopeFieldVisitor) EnterField(ref int) { // crossProduct computes the Cartesian product of two sets of OR-groups, // merging AND-scopes within each combination and deduplicating. -// Returns an error if the resulting number of combinations would exceed MaxScopeCombinations. -func crossProduct(a, b [][]string) ([][]string, error) { +// Returns an error if the resulting number of combinations would exceed the limit. +func crossProduct(a, b [][]string, maxCombinations int) ([][]string, error) { total := len(a) * len(b) - if total > MaxScopeCombinations { + if total > maxCombinations { return nil, fmt.Errorf("cross product would produce %d combinations", total) } result := make([][]string, 0, total) diff --git a/router/pkg/mcpserver/scope_extractor_test.go b/router/pkg/mcpserver/scope_extractor_test.go index 58f3b4ba54..97312118fa 100644 --- a/router/pkg/mcpserver/scope_extractor_test.go +++ b/router/pkg/mcpserver/scope_extractor_test.go @@ -288,7 +288,7 @@ func TestExtractScopesForOperation(t *testing.T) { opDoc, opReport := astparser.ParseGraphqlDocumentString(tt.operation) require.False(t, opReport.HasErrors(), "operation parse error: %s", opReport.Error()) - extractor := NewScopeExtractor(fieldConfigs, &schemaDoc) + extractor := NewScopeExtractor(fieldConfigs, &schemaDoc, 0) fieldReqs := extractor.ExtractScopesForOperation(&opDoc) if tt.wantNoScopes { @@ -307,7 +307,7 @@ func TestExtractScopesForOperation_FieldDetails(t *testing.T) { schemaDoc := parseTestSchema(t) - extractor := NewScopeExtractor(fieldConfigs, &schemaDoc) + extractor := NewScopeExtractor(fieldConfigs, &schemaDoc, 0) t.Run("root query field returns correct OR-of-AND scopes", func(t *testing.T) { t.Parallel() @@ -452,7 +452,7 @@ func TestComputeCombinedScopes(t *testing.T) { schemaDoc := parseTestSchema(t) - extractor := NewScopeExtractor(testFieldConfigs(), &schemaDoc) + extractor := NewScopeExtractor(testFieldConfigs(), &schemaDoc, 0) got, err := extractor.ComputeCombinedScopes(tt.fieldReqs) assert.NoError(t, err) assert.Equal(t, tt.want, got) @@ -464,7 +464,7 @@ func TestComputeCombinedScopes_ExceedsLimit(t *testing.T) { t.Parallel() schemaDoc := parseTestSchema(t) - extractor := NewScopeExtractor(testFieldConfigs(), &schemaDoc) + extractor := NewScopeExtractor(testFieldConfigs(), &schemaDoc, 0) // Build field requirements that will exceed MaxScopeCombinations (2048). // 12 fields × 2 OR-groups each = 2^12 = 4096 combinations > 2048. diff --git a/router/pkg/mcpserver/server.go b/router/pkg/mcpserver/server.go index 195417a04e..111c30092b 100644 --- a/router/pkg/mcpserver/server.go +++ b/router/pkg/mcpserver/server.go @@ -526,10 +526,14 @@ func (s *GraphQLSchemaServer) Reload(schema *ast.Document, fieldConfigs []*nodev // Compute per-tool scope requirements from @requiresScopes directives var scopeExtractor *ScopeExtractor if len(fieldConfigs) > 0 { - if err := s.operationsManager.ComputeToolScopes(fieldConfigs); err != nil { + maxScopeCombinations := 0 + if s.oauthConfig != nil { + maxScopeCombinations = s.oauthConfig.MaxScopeCombinations + } + if err := s.operationsManager.ComputeToolScopes(fieldConfigs, maxScopeCombinations); err != nil { return fmt.Errorf("failed to compute tool scopes: %w", err) } - scopeExtractor = NewScopeExtractor(fieldConfigs, schema) + scopeExtractor = NewScopeExtractor(fieldConfigs, schema, maxScopeCombinations) } if s.authMiddleware != nil { From 18a5dd7dc6ae81b94bd59a16464abf0c6fae2745 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 31 Mar 2026 12:27:24 +0100 Subject: [PATCH 13/46] fix: gofmt router-tests files --- router-tests/cmd/oauth-server/main.go | 4 ++-- router-tests/mcp_oauth_e2e_test.go | 2 +- router-tests/testutil/oauth_server.go | 14 +++++++------- router-tests/testutil/oauth_server_test.go | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/router-tests/cmd/oauth-server/main.go b/router-tests/cmd/oauth-server/main.go index 21e06a1eef..efd57ea1b2 100644 --- a/router-tests/cmd/oauth-server/main.go +++ b/router-tests/cmd/oauth-server/main.go @@ -361,7 +361,7 @@ func (h *oauthHandler) handleRegister(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(map[string]any{ - "client_id": id, + "client_id": id, "client_secret": secret, "client_name": req.ClientName, "grant_types": req.GrantTypes, @@ -459,4 +459,4 @@ func withCORS(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) -} \ No newline at end of file +} diff --git a/router-tests/mcp_oauth_e2e_test.go b/router-tests/mcp_oauth_e2e_test.go index c682bec748..4020d3507b 100644 --- a/router-tests/mcp_oauth_e2e_test.go +++ b/router-tests/mcp_oauth_e2e_test.go @@ -210,4 +210,4 @@ func TestMCPOAuthPerToolScopes(t *testing.T) { require.NotNil(t, result) }) }) -} \ No newline at end of file +} diff --git a/router-tests/testutil/oauth_server.go b/router-tests/testutil/oauth_server.go index 751e98345d..7d37cd17e0 100644 --- a/router-tests/testutil/oauth_server.go +++ b/router-tests/testutil/oauth_server.go @@ -365,12 +365,12 @@ func (s *OAuthTestServer) handleRegister(w http.ResponseWriter, r *http.Request) s.mu.Unlock() resp := map[string]any{ - "client_id": clientID, - "client_secret": clientSecret, - "client_name": req.ClientName, - "grant_types": req.GrantTypes, - "redirect_uris": req.RedirectURIs, - "token_endpoint_auth_method": req.TokenEndpointAuthMethod, + "client_id": clientID, + "client_secret": clientSecret, + "client_name": req.ClientName, + "grant_types": req.GrantTypes, + "redirect_uris": req.RedirectURIs, + "token_endpoint_auth_method": req.TokenEndpointAuthMethod, } w.Header().Set("Content-Type", "application/json") @@ -564,4 +564,4 @@ func withCORS(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) -} \ No newline at end of file +} diff --git a/router-tests/testutil/oauth_server_test.go b/router-tests/testutil/oauth_server_test.go index 2347c40c48..5fb555f037 100644 --- a/router-tests/testutil/oauth_server_test.go +++ b/router-tests/testutil/oauth_server_test.go @@ -231,4 +231,4 @@ func TestOAuthTestServer_CreateTokenDirectly(t *testing.T) { // Token should be a valid JWT (3 dot-separated parts) parts := strings.Split(token, ".") assert.Len(t, parts, 3) -} \ No newline at end of file +} From c8200f19963e4e4717fa3510844326aa2534dafb Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 31 Mar 2026 15:49:12 +0100 Subject: [PATCH 14/46] fix: update config golden fixtures for max_scope_combinations --- router/pkg/config/testdata/config_defaults.json | 3 ++- router/pkg/config/testdata/config_full.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/router/pkg/config/testdata/config_defaults.json b/router/pkg/config/testdata/config_defaults.json index 78f176729d..0801b32561 100644 --- a/router/pkg/config/testdata/config_defaults.json +++ b/router/pkg/config/testdata/config_defaults.json @@ -177,7 +177,8 @@ "GetOperationInfo": null, "GetSchema": null }, - "ScopeChallengeIncludeTokenScopes": false + "ScopeChallengeIncludeTokenScopes": false, + "MaxScopeCombinations": 0 }, "ResourceDocumentation": "" }, diff --git a/router/pkg/config/testdata/config_full.json b/router/pkg/config/testdata/config_full.json index cfc529a829..ec093652ab 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -222,7 +222,8 @@ "GetOperationInfo": null, "GetSchema": null }, - "ScopeChallengeIncludeTokenScopes": false + "ScopeChallengeIncludeTokenScopes": false, + "MaxScopeCombinations": 0 }, "ResourceDocumentation": "" }, From 2307f80ba609c4214addb4177d5362e48481f9cf Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 31 Mar 2026 16:36:54 +0100 Subject: [PATCH 15/46] fix(mcp): wire stateless mode to new SDK and fix integration tests --- router-tests/protocol/mcp_test.go | 46 ++++++++++++++++++++----------- router/pkg/mcpserver/server.go | 4 ++- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/router-tests/protocol/mcp_test.go b/router-tests/protocol/mcp_test.go index 04d96a5109..19899c8c22 100644 --- a/router-tests/protocol/mcp_test.go +++ b/router-tests/protocol/mcp_test.go @@ -3,6 +3,7 @@ package integration import ( "encoding/json" "fmt" + "io" "net/http" "os" "path/filepath" @@ -87,6 +88,8 @@ func TestMCP(t *testing.T) { }) // Verify execute tool with proper schema + // Note: IdempotentHint is a bool (not *bool) in the new SDK, so false + omitempty + // means it's omitted from JSON, and the old client deserializes it as nil. require.Contains(t, resp.Tools, mcp.Tool{ Name: "execute_graphql", Description: "Executes a GraphQL query or mutation.", @@ -110,7 +113,6 @@ func TestMCP(t *testing.T) { Title: "Execute GraphQL Query", DestructiveHint: mcp.ToBoolPtr(true), OpenWorldHint: mcp.ToBoolPtr(true), - IdempotentHint: mcp.ToBoolPtr(false), }, }) @@ -148,15 +150,15 @@ func TestMCP(t *testing.T) { }) // Verify UpdateMood operation + // Note: ReadOnlyHint and IdempotentHint are bool (not *bool) in the new SDK, + // so false + omitempty means they're omitted from JSON, and the old client gets nil. require.Contains(t, resp.Tools, mcp.Tool{ Name: "execute_operation_update_mood", Description: "This mutation update the mood of an employee.", InputSchema: mcp.ToolInputSchema{Type: "object", Properties: map[string]interface{}{"employeeID": map[string]interface{}{"type": "integer"}, "mood": map[string]interface{}{"enum": []interface{}{"HAPPY", "SAD"}, "type": "string"}}, Required: []string{"employeeID", "mood"}}, RawInputSchema: json.RawMessage(nil), Annotations: mcp.ToolAnnotation{ - Title: "Execute operation UpdateMood", - OpenWorldHint: mcp.ToBoolPtr(true), - ReadOnlyHint: mcp.ToBoolPtr(false), - IdempotentHint: mcp.ToBoolPtr(false), + Title: "Execute operation UpdateMood", + OpenWorldHint: mcp.ToBoolPtr(true), }, }) }) @@ -290,7 +292,7 @@ func TestMCP(t *testing.T) { assert.Equal(t, content.Type, "text") // Set up expected text with the static endpoint - expectedContent := "Operation: MyEmployees\nType: query\nDescription: This is a GraphQL query that retrieves a list of employees.\n\nInput Schema:\n```json\n{\"additionalProperties\":false,\"description\":\"This is a GraphQL query that retrieves a list of employees.\",\"nullable\":true,\"properties\":{\"criteria\":{\"additionalProperties\":false,\"description\":\"Allows to filter employees by their details.\",\"nullable\":false,\"properties\":{\"hasPets\":{\"nullable\":true,\"type\":\"boolean\"},\"nationality\":{\"enum\":[\"AMERICAN\",\"DUTCH\",\"ENGLISH\",\"GERMAN\",\"INDIAN\",\"SPANISH\",\"UKRAINIAN\"],\"nullable\":true,\"type\":\"string\"},\"nested\":{\"additionalProperties\":false,\"nullable\":true,\"properties\":{\"hasChildren\":{\"nullable\":true,\"type\":\"boolean\"},\"maritalStatus\":{\"enum\":[\"ENGAGED\",\"MARRIED\"],\"nullable\":true,\"type\":\"string\"}},\"type\":\"object\"}},\"type\":\"object\"}},\"type\":\"object\"}\n```\n\nGraphQL Query:\n```\nquery MyEmployees($criteria: SearchInput) {\n findEmployees(criteria: $criteria) {\n id\n isAvailable\n currentMood\n products\n details {\n forename\n nationality\n }\n }\n}\n```\n\nUsage Instructions:\n1. Endpoint: https://api.example.com/graphql\n2. HTTP Method: POST\n3. Headers Required:\n - Content-Type: application/json; charset=utf-8\n\nRequest Format:\n```json\n{\n \"query\": \"\",\n \"variables\": \n}\n```\n\nImportant Notes:\n1. Use the query string exactly as provided above\n2. Do not modify or reformat the query string" + expectedContent := "Operation: MyEmployees\nType: query\nDescription: This is a GraphQL query that retrieves a list of employees.\n\nInput Schema:\n```json\n{\"additionalProperties\":false,\"description\":\"This is a GraphQL query that retrieves a list of employees.\",\"nullable\":true,\"properties\":{\"criteria\":{\"additionalProperties\":false,\"description\":\"Allows to filter employees by their details.\",\"nullable\":false,\"properties\":{\"hasPets\":{\"nullable\":true,\"type\":\"boolean\"},\"nationality\":{\"enum\":[\"AMERICAN\",\"DUTCH\",\"ENGLISH\",\"GERMAN\",\"INDIAN\",\"SPANISH\",\"UKRAINIAN\"],\"nullable\":true,\"type\":\"string\"},\"nested\":{\"additionalProperties\":false,\"nullable\":true,\"properties\":{\"hasChildren\":{\"nullable\":true,\"type\":\"boolean\"},\"maritalStatus\":{\"enum\":[\"ENGAGED\",\"MARRIED\"],\"nullable\":true,\"type\":\"string\"}},\"type\":\"object\"}},\"type\":\"object\"}},\"type\":\"object\"}\n```\n\nGraphQL Query:\n```\nquery MyEmployees($criteria: SearchInput) {\n findEmployees(criteria: $criteria) {\n id\n isAvailable\n currentMood\n products\n details {\n forename\n nationality\n }\n }\n}\n```\n\nUsage Instructions:\n1. Endpoint: https://api.example.com/graphql\n2. HTTP Method: POST\n3. Headers Required:\n - Content-Type: application/json; charset=utf-8\n\nRequest Format:\n```json\n{\n \"query\": \"\",\n \"variables\": \n}\n```\nImportant Notes:\n1. Use the query string exactly as provided above\n2. Do not modify or reformat the query string" assert.Equal(t, expectedContent, content.Text) }) @@ -347,7 +349,7 @@ func TestMCP(t *testing.T) { assert.True(t, ok) assert.Equal(t, content.Type, "text") - assert.Equal(t, content.Text, "Input validation Error: validation error: at '/criteria': got null, want object") + assert.Equal(t, content.Text, "Input validation error: validation error: at '/criteria': got null, want object") }) }) }) @@ -939,10 +941,11 @@ input UserInput { // Add various headers to test forwarding req.Header.Set("Content-Type", "application/json") - req.Header.Set("foo", "bar") // Non-standard header - req.Header.Set("X-Custom-Header", "custom-value") // Custom X- header - req.Header.Set("X-Trace-Id", "trace-123") // Tracing header - req.Header.Set("Authorization", "Bearer test-token") // Auth header + req.Header.Set("Accept", "application/json, text/event-stream") // Required by Streamable HTTP transport + req.Header.Set("foo", "bar") // Non-standard header + req.Header.Set("X-Custom-Header", "custom-value") // Custom X- header + req.Header.Set("X-Trace-Id", "trace-123") // Tracing header + req.Header.Set("Authorization", "Bearer test-token") // Auth header // Make the request resp, err := xEnv.RouterClient.Do(req) @@ -953,6 +956,13 @@ input UserInput { t.Logf("Response Status: %d", resp.StatusCode) require.Equal(t, http.StatusOK, resp.StatusCode, "Request should succeed in stateless mode") + // Read the full response body - with StreamableHTTP, the response is an SSE stream + // and the tool execution completes within it. We must consume the stream fully + // before the subgraph request is guaranteed to have been made. + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + t.Logf("Response Body: %s", string(body)) + // Verify headers reached subgraph subgraphMutex.Lock() defer subgraphMutex.Unlock() @@ -1042,9 +1052,9 @@ input UserInput { // Set headers that should be filtered req.Header.Set("Proxy-Authenticate", "Basic") req.Header.Set("Proxy-Authorization", "Basic YWxhZGRpbjpvcGVuc2VzYW1l") - req.Header.Set("Content-Type", "application/json; foo=bar") // Custom param that should be stripped - req.Header.Set("Accept", "application/json") - req.Header.Set("Accept-Encoding", "br") // Request brotli (which go client doesn't support by default) + req.Header.Set("Content-Type", "application/json") // New SDK rejects non-standard content type params + req.Header.Set("Accept", "application/json, text/event-stream") // Required by Streamable HTTP transport + req.Header.Set("Accept-Encoding", "br") // Request brotli (which go client doesn't support by default) req.Header.Set("Alt-Svc", "h2=\":443\"; ma=2592000") req.Header.Set("Proxy-Connection", "keep-alive") @@ -1060,6 +1070,11 @@ input UserInput { } require.Equal(t, http.StatusOK, resp.StatusCode) + // Consume the full SSE stream so the tool execution completes + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + t.Logf("Response Body: %s", string(body)) + subgraphMutex.Lock() defer subgraphMutex.Unlock() @@ -1072,10 +1087,9 @@ input UserInput { assert.NotEqual(t, "Basic", capturedSubgraphRequest.Header.Get("Proxy-Authenticate")) assert.NotEqual(t, "Basic YWxhZGRpbjpvcGVuc2VzYW1l", capturedSubgraphRequest.Header.Get("Proxy-Authorization")) - // Content-Type should be set by MCP server to application/json (and stripped of custom params) + // Content-Type should be set by MCP server to application/json ct := capturedSubgraphRequest.Header.Get("Content-Type") assert.True(t, strings.HasPrefix(ct, "application/json"), "Content-Type should start with application/json") - assert.False(t, strings.Contains(ct, "foo=bar"), "Content-Type should not contain forwarded parameters") // Accept should be set by MCP server assert.Equal(t, "application/json", capturedSubgraphRequest.Header.Get("Accept")) diff --git a/router/pkg/mcpserver/server.go b/router/pkg/mcpserver/server.go index 111c30092b..c6b224e9ea 100644 --- a/router/pkg/mcpserver/server.go +++ b/router/pkg/mcpserver/server.go @@ -445,7 +445,9 @@ func (s *GraphQLSchemaServer) Serve() (*http.Server, error) { func(req *http.Request) *mcp.Server { return s.server }, - nil, // Use default options + &mcp.StreamableHTTPOptions{ + Stateless: s.stateless, + }, ) middleware := cors.New(s.corsConfig) From 94ce881b92344545f857a83cf17d4753e249a177 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 31 Mar 2026 16:47:44 +0100 Subject: [PATCH 16/46] test(mcp): assert 415 for non-standard Content-Type params after SDK migration --- router-tests/protocol/mcp_test.go | 45 +++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/router-tests/protocol/mcp_test.go b/router-tests/protocol/mcp_test.go index 19899c8c22..489f370208 100644 --- a/router-tests/protocol/mcp_test.go +++ b/router-tests/protocol/mcp_test.go @@ -1103,6 +1103,51 @@ input UserInput { assert.Empty(t, capturedSubgraphRequest.Header.Get("Alt-Svc")) assert.Empty(t, capturedSubgraphRequest.Header.Get("Proxy-Connection")) }) + + // Breaking change from SDK migration (mark3labs/mcp-go -> modelcontextprotocol/go-sdk): + // The old SDK accepted non-standard Content-Type params (e.g., "application/json; foo=bar") + // and silently stripped them. The new SDK's StreamableHTTPHandler rejects them with 415 + // Unsupported Media Type at the transport level before our code runs. + // + // This is the correct behavior per the MCP spec which requires "application/json". + // No legitimate MCP client sends custom content-type params. + t.Run("Non-standard Content-Type params are rejected by the SDK", func(t *testing.T) { + testenv.Run(t, &testenv.Config{ + MCP: config.MCPConfiguration{ + Enabled: true, + Session: config.MCPSessionConfig{ + Stateless: true, + }, + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + mcpAddr := xEnv.GetMCPServerAddr() + + mcpRequest := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": map[string]interface{}{ + "name": "execute_operation_my_employees", + "arguments": map[string]interface{}{}, + }, + } + + requestBody, err := json.Marshal(mcpRequest) + require.NoError(t, err) + + req, err := http.NewRequest("POST", mcpAddr, strings.NewReader(string(requestBody))) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json; foo=bar") + req.Header.Set("Accept", "application/json, text/event-stream") + + resp, err := xEnv.RouterClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + assert.Equal(t, http.StatusUnsupportedMediaType, resp.StatusCode, + "New SDK rejects non-standard Content-Type params with 415") + }) + }) }) }) } From 76d3aea5b2f185d651a933cd06dedd98379023e3 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Thu, 2 Apr 2026 14:57:56 +0100 Subject: [PATCH 17/46] fix(mcp): disable SDK cross-origin protection in favor of router CORS middleware The MCP Go SDK v1.4.1 uses Go 1.25's net/http.CrossOriginProtection which rejects cross-origin browser requests via the Sec-Fetch-Site header. This conflicts with the router's own CORS middleware that already permits cross-origin access (e.g. from the MCP Inspector on a different port). --- router/pkg/mcpserver/server.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/router/pkg/mcpserver/server.go b/router/pkg/mcpserver/server.go index c6b224e9ea..ebce93b8dc 100644 --- a/router/pkg/mcpserver/server.go +++ b/router/pkg/mcpserver/server.go @@ -441,12 +441,18 @@ func (s *GraphQLSchemaServer) Serve() (*http.Server, error) { // Create MCP streamable HTTP handler // The getServer function returns our MCP server instance for each request + // Disable the SDK's built-in cross-origin protection (Sec-Fetch-Site check) + // because the router already applies its own CORS middleware around the handler. + cop := http.NewCrossOriginProtection() + cop.AddInsecureBypassPattern("/{path...}") + streamableHTTPHandler := mcp.NewStreamableHTTPHandler( func(req *http.Request) *mcp.Server { return s.server }, &mcp.StreamableHTTPOptions{ - Stateless: s.stateless, + Stateless: s.stateless, + CrossOriginProtection: cop, }, ) From a9df0f7c053113da7fa901b8f3869df79010404a Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 7 Apr 2026 16:25:46 +0100 Subject: [PATCH 18/46] test(mcp): expand OAuth scope E2E tests and add debug tooling Add built-in tool scope enforcement tests (execute_graphql, get_schema, get_operation_info) and a tools_call gate test. Create a dedicated test config with separated scope levels. Include a debug proxy and OAuth server request logging for tracing MCP OAuth flows. --- client-tests/mcp-ts/mcp-debug-proxy.mjs | 90 +++ client-tests/mcp-ts/mcp.test.config.yaml | 67 ++ .../src/__tests__/McpOAuthScopes.test.ts | 639 ++++++++++++++++++ router-tests/cmd/oauth-server/main.go | 29 +- 4 files changed, 824 insertions(+), 1 deletion(-) create mode 100644 client-tests/mcp-ts/mcp-debug-proxy.mjs create mode 100644 client-tests/mcp-ts/mcp.test.config.yaml create mode 100644 client-tests/mcp-ts/src/__tests__/McpOAuthScopes.test.ts diff --git a/client-tests/mcp-ts/mcp-debug-proxy.mjs b/client-tests/mcp-ts/mcp-debug-proxy.mjs new file mode 100644 index 0000000000..74737c9513 --- /dev/null +++ b/client-tests/mcp-ts/mcp-debug-proxy.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node +/** + * MCP Debug Proxy — logs all requests/responses between Claude Code and the MCP server. + * Usage: node mcp-debug-proxy.mjs [listen-port] [target-port] + * Defaults: listen on 5026, forward to localhost:5025 + * + * Then update ~/.claude.json: "url": "http://localhost:5026/mcp" + */ + +import http from 'node:http'; + +const LISTEN_PORT = parseInt(process.argv[2] || '5026', 10); +const TARGET_PORT = parseInt(process.argv[3] || '5025', 10); +const TARGET_HOST = '127.0.0.1'; + +function timestamp() { + return new Date().toISOString(); +} + +function logHeaders(prefix, headers) { + for (const [k, v] of Object.entries(headers)) { + console.log(` ${prefix} ${k}: ${v}`); + } +} + +const server = http.createServer((clientReq, clientRes) => { + const id = Math.random().toString(36).slice(2, 8); + const chunks = []; + + clientReq.on('data', (chunk) => chunks.push(chunk)); + clientReq.on('end', () => { + const body = Buffer.concat(chunks).toString(); + + console.log(`\n${'='.repeat(80)}`); + console.log(`[${timestamp()}] ▶ REQUEST ${id} ${clientReq.method} ${clientReq.url}`); + logHeaders('→', clientReq.headers); + if (body) { + try { + console.log(` → BODY:`, JSON.stringify(JSON.parse(body), null, 2)); + } catch { + console.log(` → BODY:`, body.slice(0, 2000)); + } + } + + const proxyReq = http.request( + { + hostname: TARGET_HOST, + port: TARGET_PORT, + path: clientReq.url, + method: clientReq.method, + headers: { ...clientReq.headers, host: `${TARGET_HOST}:${TARGET_PORT}` }, + }, + (proxyRes) => { + const resChunks = []; + proxyRes.on('data', (chunk) => resChunks.push(chunk)); + proxyRes.on('end', () => { + const resBody = Buffer.concat(resChunks).toString(); + + console.log(`\n[${timestamp()}] ◀ RESPONSE ${id} ${proxyRes.statusCode} ${proxyRes.statusMessage}`); + logHeaders('←', proxyRes.headers); + if (resBody) { + try { + console.log(` ← BODY:`, JSON.stringify(JSON.parse(resBody), null, 2)); + } catch { + // SSE or non-JSON — print raw but truncated + console.log(` ← BODY:`, resBody.slice(0, 4000)); + } + } + console.log(`${'='.repeat(80)}\n`); + + clientRes.writeHead(proxyRes.statusCode, proxyRes.headers); + clientRes.end(Buffer.concat(resChunks)); + }); + }, + ); + + proxyReq.on('error', (err) => { + console.error(`[${timestamp()}] ✗ PROXY ERROR ${id}:`, err.message); + clientRes.writeHead(502); + clientRes.end('Bad Gateway'); + }); + + proxyReq.end(body); + }); +}); + +server.listen(LISTEN_PORT, () => { + console.log(`MCP Debug Proxy listening on :${LISTEN_PORT} → forwarding to ${TARGET_HOST}:${TARGET_PORT}`); + console.log(`Update ~/.claude.json to: "url": "http://localhost:${LISTEN_PORT}/mcp"\n`); +}); \ No newline at end of file diff --git a/client-tests/mcp-ts/mcp.test.config.yaml b/client-tests/mcp-ts/mcp.test.config.yaml new file mode 100644 index 0000000000..86bd4e49a7 --- /dev/null +++ b/client-tests/mcp-ts/mcp.test.config.yaml @@ -0,0 +1,67 @@ +# MCP OAuth Scope Enforcement — Test Configuration +# yaml-language-server: $schema=../../router/pkg/config/config.schema.json +# +# This config is used by the E2E tests in McpOAuthScopes.test.ts. +# It adds built-in tool scope requirements on top of the base MCP OAuth setup. +# +# Usage: +# 1. Start the test OAuth server: +# go run ./router-tests/cmd/oauth-server +# 2. Start the router with this config (from the repo root): +# go run ./router/cmd/router -config client-tests/mcp-ts/mcp.test.config.yaml +# 3. Run the tests: +# cd client-tests/mcp-ts && pnpm test + +authentication: + jwt: + jwks: + - url: 'http://localhost:9099/.well-known/jwks.json' + refresh_interval: 1m + +execution_config: + file: + path: '../router-tests/testenv/testdata/config.json' + watch: true + watch_interval: 20s + +mcp: + enabled: true + graph_name: 'my-graph' + omit_tool_name_prefix: true + + server: + listen_addr: 'localhost:5025' + base_url: 'http://localhost:5026' + + oauth: + enabled: true + authorization_server_url: 'http://localhost:9099' + jwks: + - url: 'http://localhost:9099/.well-known/jwks.json' + refresh_interval: 1m + algorithms: ['RS256'] + scope_challenge_include_token_scopes: true + scopes: + initialize: ['mcp:connect'] + tools_list: ['mcp:tools:read'] + tools_call: ['mcp:tools:call'] + execute_graphql: ['mcp:graphql:execute'] + get_schema: ['mcp:schema:read'] + get_operation_info: ['mcp:ops:read'] + + storage: + provider_id: 'mcp-operations' + + session: + stateless: true + + exclude_mutations: false + enable_arbitrary_operations: true + expose_schema: true + + router_url: 'http://localhost:3002/graphql' + +storage_providers: + file_system: + - id: 'mcp-operations' + path: './../../connectrpc-tutorial/services' diff --git a/client-tests/mcp-ts/src/__tests__/McpOAuthScopes.test.ts b/client-tests/mcp-ts/src/__tests__/McpOAuthScopes.test.ts new file mode 100644 index 0000000000..6694bbd12e --- /dev/null +++ b/client-tests/mcp-ts/src/__tests__/McpOAuthScopes.test.ts @@ -0,0 +1,639 @@ +/** + * MCP OAuth Scope Enforcement — E2E Tests + * + * Validates the Cosmo Router's MCP OAuth 2.1 scope enforcement from an MCP + * client's perspective, using raw HTTP requests and the official MCP TypeScript + * SDK. The router enforces scopes at five additive levels; these tests verify + * each level returns the correct HTTP status and WWW-Authenticate challenge. + * + * ## Test Sections + * + * A. Metadata Discovery + * - Protected Resource Metadata (RFC 9728) exposes scopes_supported + * - Authorization Server Metadata (RFC 8414) exposes endpoints + * + * B. Dynamic Client Registration (DCR) + * - Register a new client via RFC 7591 and obtain tokens + * + * C. Operation-Level Scope Enforcement + * - initialize requires mcp:connect + * - tools/list requires mcp:tools:read + * - tools/list rejected without mcp:tools:read (403) + * + * D. Per-Tool Scope Enforcement (@requiresScopes) + * - Scoped tools rejected without per-tool scopes (403) + * - Scoped tools allowed with correct OR-of-AND scope group + * - Unscoped tools allowed with base scopes + * - scope_challenge_include_token_scopes includes held scopes in challenge + * + * E. tools_call Gate + * - tools/call rejected without mcp:tools:call (403) + * + * F. Built-in Tool Scope Enforcement + * - execute_graphql requires mcp:graphql:execute + * - get_schema requires mcp:schema:read + * - get_operation_info requires mcp:ops:read + * - Each tested with reject (403) and allow (200) + * + * G. MCP SDK Client E2E (ClientCredentialsProvider) + * - Full connect → list → call flow via the MCP TypeScript SDK + * - Verifies scoped tool throws 403 when scopes are missing + * + * ## Scope Hierarchy (mcp.test.config.yaml) + * + * | Level | Scopes | Gates | + * |--------------------|-------------------------|------------------------------------| + * | Initialize | mcp:connect | All HTTP requests | + * | tools/list | mcp:tools:read | Discovering tools | + * | tools/call (any) | mcp:tools:call | Calling any tool | + * | execute_graphql | mcp:graphql:execute | Arbitrary GraphQL queries | + * | get_schema | mcp:schema:read | Introspecting the schema | + * | get_operation_info | mcp:ops:read | Viewing operation metadata | + * | Per-tool | @requiresScopes scopes | Calling specific scoped operations | + * + * ## Prerequisites + * + * 1. Start the test OAuth server: + * go run ./router-tests/cmd/oauth-server + * 2. Start the router with the test config (from repo root): + * go run ./router/cmd/router -config client-tests/mcp-ts/mcp.test.config.yaml + * 3. Run the tests: + * cd client-tests/mcp-ts && pnpm test + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { ClientCredentialsProvider } from '@modelcontextprotocol/sdk/client/auth-extensions.js'; +import { describe, it, expect, beforeAll } from 'vitest'; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +const MCP_SERVER_URL = process.env.MCP_SERVER_URL || 'http://localhost:5026/mcp'; +const MCP_BASE_URL = MCP_SERVER_URL.replace(/\/mcp$/, ''); +const OAUTH_SERVER_URL = process.env.OAUTH_SERVER_URL || 'http://localhost:9099'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Fetch JSON from a URL. */ +async function fetchJSON(url: string, init?: RequestInit): Promise { + const res = await fetch(url, init); + if (!res.ok) throw new Error(`${res.status} ${res.statusText}: ${url}`); + return res.json() as Promise; +} + +/** Register a new OAuth client via DCR. Returns client_id + client_secret. */ +async function registerClient(registrationEndpoint: string): Promise<{ clientId: string; clientSecret: string }> { + const res = await fetch(registrationEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'MCP E2E Test (DCR)', + redirect_uris: ['http://localhost:6274/oauth/callback'], + grant_types: ['client_credentials'], + token_endpoint_auth_method: 'client_secret_basic', + }), + }); + if (!res.ok) throw new Error(`DCR failed: ${res.status}`); + const body = (await res.json()) as { client_id: string; client_secret: string }; + return { clientId: body.client_id, clientSecret: body.client_secret }; +} + +/** Get an access token via client_credentials grant (Basic auth). */ +async function getToken( + tokenEndpoint: string, + clientId: string, + clientSecret: string, + scope: string, +): Promise<{ access_token: string; scope: string }> { + const res = await fetch(tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: 'Basic ' + btoa(`${clientId}:${clientSecret}`), + }, + body: new URLSearchParams({ grant_type: 'client_credentials', scope }), + }); + if (!res.ok) throw new Error(`Token request failed: ${res.status}`); + return res.json() as Promise<{ access_token: string; scope: string }>; +} + +/** + * Send a raw JSON-RPC request to the MCP endpoint and return the HTTP response. + * Does NOT follow the SSE stream — returns the raw response for header inspection. + */ +async function rawMcpRequest(token: string, sessionId: string | null, body: object): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }; + if (sessionId) headers['Mcp-Session-Id'] = sessionId; + + return fetch(MCP_SERVER_URL, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); +} + +/** Extract the Mcp-Session-Id from an initialize response. */ +function getSessionId(res: Response): string { + const sid = res.headers.get('mcp-session-id'); + if (!sid) throw new Error('No Mcp-Session-Id header in response'); + return sid; +} + +/** Parse the WWW-Authenticate header into key-value pairs. */ +function parseWWWAuthenticate(res: Response): Record { + const header = res.headers.get('www-authenticate'); + if (!header) return {}; + const params: Record = {}; + for (const match of header.matchAll(/(\w+)="([^"]*)"/g)) { + params[match[1]] = match[2]; + } + return params; +} + +/** Initialize an MCP session and return the session ID. */ +async function initSession(token: string): Promise { + const res = await rawMcpRequest(token, null, { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } }, + }); + if (res.status !== 200) throw new Error(`Initialize failed: ${res.status}`); + return getSessionId(res); +} + +// --------------------------------------------------------------------------- +// Shared state +// --------------------------------------------------------------------------- + +let tokenEndpoint: string; +let registrationEndpoint: string; +let clientId: string; +let clientSecret: string; + +// ========================================================================== +// Test Suites +// ========================================================================== + +describe('MCP OAuth Scope Enforcement E2E', () => { + // ------------------------------------------------------------------------ + // Setup: discover endpoints + register a client via DCR + // ------------------------------------------------------------------------ + beforeAll(async () => { + // 1. Discover protected resource metadata + const resourceMeta = await fetchJSON<{ + authorization_servers: string[]; + scopes_supported: string[]; + }>(`${MCP_BASE_URL}/.well-known/oauth-protected-resource/mcp`); + + expect(resourceMeta.authorization_servers).toContain(OAUTH_SERVER_URL); + expect(resourceMeta.scopes_supported).toEqual(expect.arrayContaining(['mcp:connect', 'mcp:tools:read'])); + + // 2. Discover authorization server metadata + const asMeta = await fetchJSON<{ + token_endpoint: string; + registration_endpoint: string; + }>(`${OAUTH_SERVER_URL}/.well-known/oauth-authorization-server`); + + tokenEndpoint = asMeta.token_endpoint; + registrationEndpoint = asMeta.registration_endpoint; + + // 3. Dynamic Client Registration + const client = await registerClient(registrationEndpoint); + clientId = client.clientId; + clientSecret = client.clientSecret; + }); + + // ======================================================================== + // A. Metadata Discovery + // ======================================================================== + + describe('Metadata Discovery', () => { + it('should expose protected resource metadata with all scopes', async () => { + const meta = await fetchJSON<{ + resource: string; + authorization_servers: string[]; + scopes_supported: string[]; + bearer_methods_supported: string[]; + }>(`${MCP_BASE_URL}/.well-known/oauth-protected-resource/mcp`); + + expect(meta.resource).toBe(MCP_SERVER_URL); + expect(meta.authorization_servers).toEqual([OAUTH_SERVER_URL]); + expect(meta.bearer_methods_supported).toContain('header'); + expect(meta.scopes_supported).toEqual(expect.arrayContaining(['mcp:connect', 'mcp:tools:read'])); + }); + + it('should expose authorization server metadata', async () => { + const meta = await fetchJSON<{ + issuer: string; + token_endpoint: string; + registration_endpoint: string; + grant_types_supported: string[]; + }>(`${OAUTH_SERVER_URL}/.well-known/oauth-authorization-server`); + + expect(meta.issuer).toBe(OAUTH_SERVER_URL); + expect(meta.token_endpoint).toBeTruthy(); + expect(meta.registration_endpoint).toBeTruthy(); + expect(meta.grant_types_supported).toContain('client_credentials'); + }); + }); + + // ======================================================================== + // B. Dynamic Client Registration + Token Acquisition + // ======================================================================== + + describe('Dynamic Client Registration', () => { + it('should register a new client and obtain a token', async () => { + const newClient = await registerClient(registrationEndpoint); + expect(newClient.clientId).toMatch(/^dyn-/); + expect(newClient.clientSecret).toBeTruthy(); + + // Verify the DCR client can obtain a token + const token = await getToken(tokenEndpoint, newClient.clientId, newClient.clientSecret, 'mcp:connect'); + expect(token.access_token).toBeTruthy(); + expect(token.scope).toContain('mcp:connect'); + }); + + it('should obtain a token with multiple scopes', async () => { + const token = await getToken(tokenEndpoint, clientId, clientSecret, 'mcp:connect mcp:tools:read read:all'); + expect(token.access_token).toBeTruthy(); + expect(token.scope).toContain('mcp:connect'); + expect(token.scope).toContain('mcp:tools:read'); + expect(token.scope).toContain('read:all'); + }); + }); + + // ======================================================================== + // C. MCP Operation-Level Scope Enforcement (raw HTTP) + // ======================================================================== + + describe('Operation-Level Scope Enforcement', () => { + it('should allow initialize with mcp:connect scope', async () => { + const { access_token } = await getToken(tokenEndpoint, clientId, clientSecret, 'mcp:connect'); + + const res = await rawMcpRequest(access_token, null, { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } }, + }); + + expect(res.status).toBe(200); + expect(res.headers.get('mcp-session-id')).toBeTruthy(); + }); + + it('should reject tools/list with only mcp:connect (missing mcp:tools:read)', async () => { + const { access_token } = await getToken(tokenEndpoint, clientId, clientSecret, 'mcp:connect'); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + params: {}, + }); + + expect(res.status).toBe(403); + + const auth = parseWWWAuthenticate(res); + expect(auth.error).toBe('insufficient_scope'); + expect(auth.scope).toContain('mcp:tools:read'); + expect(auth.resource_metadata).toContain('.well-known/oauth-protected-resource'); + }); + + it('should allow tools/list with mcp:connect + mcp:tools:read', async () => { + const { access_token } = await getToken(tokenEndpoint, clientId, clientSecret, 'mcp:connect mcp:tools:read'); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + params: {}, + }); + + expect(res.status).toBe(200); + }); + }); + + // ======================================================================== + // D. Per-Tool Scope Enforcement (raw HTTP) + // ======================================================================== + + describe('Per-Tool Scope Enforcement', () => { + const BASE_CALL_SCOPES = 'mcp:connect mcp:tools:read mcp:tools:call'; + + it('should reject scoped tool with only base scopes', async () => { + const { access_token } = await getToken(tokenEndpoint, clientId, clientSecret, BASE_CALL_SCOPES); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'get_employee_start_date', arguments: { id: 1 } }, + }); + + expect(res.status).toBe(403); + + const auth = parseWWWAuthenticate(res); + expect(auth.error).toBe('insufficient_scope'); + expect(auth.error_description).toContain('get_employee_start_date'); + expect(auth.scope).toBeTruthy(); + }); + + it('should allow scoped tool with read:all', async () => { + const { access_token } = await getToken(tokenEndpoint, clientId, clientSecret, `${BASE_CALL_SCOPES} read:all`); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'get_employee_start_date', arguments: { id: 1 } }, + }); + + expect(res.status).toBe(200); + }); + + it('should allow scoped tool with read:employee + read:private (alternative AND-group)', async () => { + const { access_token } = await getToken( + tokenEndpoint, + clientId, + clientSecret, + `${BASE_CALL_SCOPES} read:employee read:private`, + ); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'get_employee_start_date', arguments: { id: 1 } }, + }); + + expect(res.status).toBe(200); + }); + + it('should allow unscoped tool with just base scopes', async () => { + const { access_token } = await getToken(tokenEndpoint, clientId, clientSecret, BASE_CALL_SCOPES); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'get_employees', arguments: {} }, + }); + + expect(res.status).toBe(200); + }); + + it('should include existing scopes in challenge when partially scoped', async () => { + const { access_token } = await getToken( + tokenEndpoint, + clientId, + clientSecret, + `${BASE_CALL_SCOPES} read:employee`, + ); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'get_employee_start_date', arguments: { id: 1 } }, + }); + + expect(res.status).toBe(403); + + const auth = parseWWWAuthenticate(res); + expect(auth.error).toBe('insufficient_scope'); + // With scope_challenge_include_token_scopes: true, the challenge includes + // both the held scope (read:employee) and the missing one (read:private) + const challengedScopes = auth.scope?.split(' ') ?? []; + expect(challengedScopes).toContain('read:employee'); + expect(challengedScopes).toContain('read:private'); + }); + }); + + // ======================================================================== + // E. tools_call Gate (raw HTTP) + // ======================================================================== + + describe('tools_call Gate', () => { + it('should reject tools/call without mcp:tools:call scope', async () => { + const { access_token } = await getToken(tokenEndpoint, clientId, clientSecret, 'mcp:connect mcp:tools:read'); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'get_employees', arguments: {} }, + }); + + expect(res.status).toBe(403); + + const auth = parseWWWAuthenticate(res); + expect(auth.error).toBe('insufficient_scope'); + expect(auth.scope).toContain('mcp:tools:call'); + }); + }); + + // ======================================================================== + // F. Built-in Tool Scope Enforcement (raw HTTP) + // ======================================================================== + + describe('Built-in Tool Scope Enforcement', () => { + const BASE_CALL_SCOPES = 'mcp:connect mcp:tools:read mcp:tools:call'; + + // -- execute_graphql -------------------------------------------------- + + it('should reject execute_graphql without mcp:graphql:execute scope', async () => { + const { access_token } = await getToken(tokenEndpoint, clientId, clientSecret, BASE_CALL_SCOPES); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'execute_graphql', arguments: { query: '{ employees { id } }' } }, + }); + + expect(res.status).toBe(403); + + const auth = parseWWWAuthenticate(res); + expect(auth.error).toBe('insufficient_scope'); + expect(auth.scope).toContain('mcp:graphql:execute'); + }); + + it('should allow execute_graphql with mcp:graphql:execute scope', async () => { + const { access_token } = await getToken( + tokenEndpoint, + clientId, + clientSecret, + `${BASE_CALL_SCOPES} mcp:graphql:execute`, + ); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'execute_graphql', arguments: { query: '{ employees { id } }' } }, + }); + + expect(res.status).toBe(200); + }); + + // -- get_schema ------------------------------------------------------- + + it('should reject get_schema without mcp:schema:read scope', async () => { + const { access_token } = await getToken(tokenEndpoint, clientId, clientSecret, BASE_CALL_SCOPES); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'get_schema', arguments: {} }, + }); + + expect(res.status).toBe(403); + + const auth = parseWWWAuthenticate(res); + expect(auth.error).toBe('insufficient_scope'); + expect(auth.scope).toContain('mcp:schema:read'); + }); + + it('should allow get_schema with mcp:schema:read scope', async () => { + const { access_token } = await getToken( + tokenEndpoint, + clientId, + clientSecret, + `${BASE_CALL_SCOPES} mcp:schema:read`, + ); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'get_schema', arguments: {} }, + }); + + expect(res.status).toBe(200); + }); + + // -- get_operation_info ------------------------------------------------ + + it('should reject get_operation_info without mcp:ops:read scope', async () => { + const { access_token } = await getToken(tokenEndpoint, clientId, clientSecret, BASE_CALL_SCOPES); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'get_operation_info', arguments: { operationName: 'GetEmployees' } }, + }); + + expect(res.status).toBe(403); + + const auth = parseWWWAuthenticate(res); + expect(auth.error).toBe('insufficient_scope'); + expect(auth.scope).toContain('mcp:ops:read'); + }); + + it('should allow get_operation_info with mcp:ops:read scope', async () => { + const { access_token } = await getToken( + tokenEndpoint, + clientId, + clientSecret, + `${BASE_CALL_SCOPES} mcp:ops:read`, + ); + const sessionId = await initSession(access_token); + + const res = await rawMcpRequest(access_token, sessionId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'get_operation_info', arguments: { operationName: 'GetEmployees' } }, + }); + + expect(res.status).toBe(200); + }); + }); + + // ======================================================================== + // G. MCP SDK Client — Full E2E with ClientCredentialsProvider + // ======================================================================== + + describe('MCP SDK Client E2E', () => { + /** Helper to create a connected MCP client with given scopes. */ + async function createClient(scope: string): Promise { + const provider = new ClientCredentialsProvider({ + clientId, + clientSecret, + scope, + }); + + const transport = new StreamableHTTPClientTransport(new URL(MCP_SERVER_URL), { + authProvider: provider, + }); + + const client = new Client({ name: 'e2e-sdk-test', version: '1.0.0' }, { capabilities: {} }); + + await client.connect(transport); + return client; + } + + it('should connect, list tools, and call an unscoped tool', async () => { + const client = await createClient('mcp:connect mcp:tools:read mcp:tools:call'); + + const tools = await client.listTools(); + expect(tools.tools.length).toBeGreaterThan(0); + + const toolNames = tools.tools.map((t) => t.name); + expect(toolNames).toContain('get_employees'); + expect(toolNames).toContain('get_employee_start_date'); + + const result = await client.callTool({ name: 'get_employees', arguments: {} }); + expect(result.content).toBeDefined(); + + await client.close(); + }); + + it('should connect and call a scoped tool with sufficient scopes', async () => { + const client = await createClient('mcp:connect mcp:tools:read mcp:tools:call read:all'); + + const result = await client.callTool({ + name: 'get_employee_start_date', + arguments: { id: 1 }, + }); + + expect(result.content).toBeDefined(); + + await client.close(); + }); + + it('should surface a 403 error when calling a scoped tool without per-tool scopes', async () => { + const client = await createClient('mcp:connect mcp:tools:read mcp:tools:call'); + + // Calling a scoped tool without per-tool scopes should throw. + // The MCP client is responsible for handling this 403 and acquiring + // the additional scopes indicated in the WWW-Authenticate header. + await expect(client.callTool({ name: 'get_employee_start_date', arguments: { id: 1 } })).rejects.toThrow(/403/); + + await client.close(); + }); + }); +}); diff --git a/router-tests/cmd/oauth-server/main.go b/router-tests/cmd/oauth-server/main.go index efd57ea1b2..cfabe91e6c 100644 --- a/router-tests/cmd/oauth-server/main.go +++ b/router-tests/cmd/oauth-server/main.go @@ -46,6 +46,8 @@ import ( "fmt" "log" "net/http" + "net/http/httptest" + "net/http/httputil" "os" "os/signal" "strings" @@ -175,7 +177,7 @@ func newOAuthServer(port, clientID, clientSecret, defaultScopes string) (*server mux.HandleFunc("/authorize", h.handleAuthorize) return &serverWithHandler{ - Server: &http.Server{Addr: ":" + port, Handler: withCORS(mux)}, + Server: &http.Server{Addr: ":" + port, Handler: withDebugLog(withCORS(mux))}, handler: h, }, nil } @@ -460,3 +462,28 @@ func withCORS(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } + +// withDebugLog wraps an http.Handler to dump full request and response details. +func withDebugLog(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reqDump, err := httputil.DumpRequest(r, true) + if err != nil { + log.Printf("[DEBUG] ▶ REQUEST dump error: %v", err) + } else { + log.Printf("[DEBUG] ▶ REQUEST %s %s\n%s", r.Method, r.URL.Path, string(reqDump)) + } + + rec := httptest.NewRecorder() + next.ServeHTTP(rec, r) + + log.Printf("[DEBUG] ◀ RESPONSE %s %s → %d\n Headers: %v\n Body: %s", + r.Method, r.URL.Path, rec.Code, rec.Header(), rec.Body.String()) + + // Copy recorded response to the real writer + for k, v := range rec.Header() { + w.Header()[k] = v + } + w.WriteHeader(rec.Code) + _, _ = w.Write(rec.Body.Bytes()) + }) +} From 41a14482654f74d5f74dc106bce17ece92087c9b Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 7 Apr 2026 16:26:48 +0100 Subject: [PATCH 19/46] test(mcp): add MCP OAuth E2E test project files Package config, lockfile, TypeScript config, and vitest setup for the client-tests/mcp-ts test suite. --- client-tests/mcp-ts/package-lock.json | 2809 +++++++++++++++++++++++++ client-tests/mcp-ts/package.json | 20 + client-tests/mcp-ts/tsconfig.json | 16 + client-tests/mcp-ts/vitest.config.ts | 10 + 4 files changed, 2855 insertions(+) create mode 100644 client-tests/mcp-ts/package-lock.json create mode 100644 client-tests/mcp-ts/package.json create mode 100644 client-tests/mcp-ts/tsconfig.json create mode 100644 client-tests/mcp-ts/vitest.config.ts diff --git a/client-tests/mcp-ts/package-lock.json b/client-tests/mcp-ts/package-lock.json new file mode 100644 index 0000000000..c97ab5a1a0 --- /dev/null +++ b/client-tests/mcp-ts/package-lock.json @@ -0,0 +1,2809 @@ +{ + "name": "mcp-ts", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mcp-ts", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "@types/node": "^22.19.15", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.12.tgz", + "integrity": "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.10", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.10.tgz", + "integrity": "sha512-mx/p18PLy5og9ufies2GOSUqep98Td9q4i/EF6X7yJgAiIopxqdfIO3jbqsi3jRgTgw88jMDEzVKi+V2EF+27w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/client-tests/mcp-ts/package.json b/client-tests/mcp-ts/package.json new file mode 100644 index 0000000000..23f39804d5 --- /dev/null +++ b/client-tests/mcp-ts/package.json @@ -0,0 +1,20 @@ +{ + "name": "mcp-ts", + "version": "1.0.0", + "description": "MCP OAuth scope enforcement E2E tests using the official MCP TypeScript SDK", + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest watch" + }, + "keywords": [], + "author": "", + "license": "ISC", + "packageManager": "pnpm@9.12.3", + "devDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "@types/node": "^22.19.15", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + } +} diff --git a/client-tests/mcp-ts/tsconfig.json b/client-tests/mcp-ts/tsconfig.json new file mode 100644 index 0000000000..b00c98512e --- /dev/null +++ b/client-tests/mcp-ts/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "es2022", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist", + "rootDir": "./src", + "types": ["node", "vitest"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/client-tests/mcp-ts/vitest.config.ts b/client-tests/mcp-ts/vitest.config.ts new file mode 100644 index 0000000000..2655f53739 --- /dev/null +++ b/client-tests/mcp-ts/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.{test,spec}.ts'], + testTimeout: 30_000, + }, +}); From 0b394fab4bb7db43e524f5594e26c79a486a1971 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 7 Apr 2026 16:29:17 +0100 Subject: [PATCH 20/46] fix: update debug proxy description to be client-agnostic --- client-tests/mcp-ts/mcp-debug-proxy.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client-tests/mcp-ts/mcp-debug-proxy.mjs b/client-tests/mcp-ts/mcp-debug-proxy.mjs index 74737c9513..da7c1419c2 100644 --- a/client-tests/mcp-ts/mcp-debug-proxy.mjs +++ b/client-tests/mcp-ts/mcp-debug-proxy.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * MCP Debug Proxy — logs all requests/responses between Claude Code and the MCP server. + * MCP Debug Proxy — logs all requests/responses between an MCP client and the MCP server. * Usage: node mcp-debug-proxy.mjs [listen-port] [target-port] * Defaults: listen on 5026, forward to localhost:5025 * From 8b2cad3fe5871611f9d696388714f1d48b88d38b Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 14 Apr 2026 09:23:23 +0100 Subject: [PATCH 21/46] chore: license mcp-ts as Apache-2.0 --- client-tests/mcp-ts/package-lock.json | 2 +- client-tests/mcp-ts/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client-tests/mcp-ts/package-lock.json b/client-tests/mcp-ts/package-lock.json index c97ab5a1a0..2264cc73a1 100644 --- a/client-tests/mcp-ts/package-lock.json +++ b/client-tests/mcp-ts/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "mcp-ts", "version": "1.0.0", - "license": "ISC", + "license": "Apache-2.0", "devDependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@types/node": "^22.19.15", diff --git a/client-tests/mcp-ts/package.json b/client-tests/mcp-ts/package.json index 23f39804d5..24cbc3ca30 100644 --- a/client-tests/mcp-ts/package.json +++ b/client-tests/mcp-ts/package.json @@ -9,7 +9,7 @@ }, "keywords": [], "author": "", - "license": "ISC", + "license": "Apache-2.0", "packageManager": "pnpm@9.12.3", "devDependencies": { "@modelcontextprotocol/sdk": "^1.29.0", From 61a599492dec6727428c08077e0decb487140173 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 14 Apr 2026 09:34:20 +0100 Subject: [PATCH 22/46] test(mcp): make expected operation info readable with raw strings --- router-tests/protocol/mcp_test.go | 44 +++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/router-tests/protocol/mcp_test.go b/router-tests/protocol/mcp_test.go index 489f370208..ded7b0fc17 100644 --- a/router-tests/protocol/mcp_test.go +++ b/router-tests/protocol/mcp_test.go @@ -291,8 +291,48 @@ func TestMCP(t *testing.T) { assert.Equal(t, content.Type, "text") - // Set up expected text with the static endpoint - expectedContent := "Operation: MyEmployees\nType: query\nDescription: This is a GraphQL query that retrieves a list of employees.\n\nInput Schema:\n```json\n{\"additionalProperties\":false,\"description\":\"This is a GraphQL query that retrieves a list of employees.\",\"nullable\":true,\"properties\":{\"criteria\":{\"additionalProperties\":false,\"description\":\"Allows to filter employees by their details.\",\"nullable\":false,\"properties\":{\"hasPets\":{\"nullable\":true,\"type\":\"boolean\"},\"nationality\":{\"enum\":[\"AMERICAN\",\"DUTCH\",\"ENGLISH\",\"GERMAN\",\"INDIAN\",\"SPANISH\",\"UKRAINIAN\"],\"nullable\":true,\"type\":\"string\"},\"nested\":{\"additionalProperties\":false,\"nullable\":true,\"properties\":{\"hasChildren\":{\"nullable\":true,\"type\":\"boolean\"},\"maritalStatus\":{\"enum\":[\"ENGAGED\",\"MARRIED\"],\"nullable\":true,\"type\":\"string\"}},\"type\":\"object\"}},\"type\":\"object\"}},\"type\":\"object\"}\n```\n\nGraphQL Query:\n```\nquery MyEmployees($criteria: SearchInput) {\n findEmployees(criteria: $criteria) {\n id\n isAvailable\n currentMood\n products\n details {\n forename\n nationality\n }\n }\n}\n```\n\nUsage Instructions:\n1. Endpoint: https://api.example.com/graphql\n2. HTTP Method: POST\n3. Headers Required:\n - Content-Type: application/json; charset=utf-8\n\nRequest Format:\n```json\n{\n \"query\": \"\",\n \"variables\": \n}\n```\nImportant Notes:\n1. Use the query string exactly as provided above\n2. Do not modify or reformat the query string" + bt := "```" + expectedContent := `Operation: MyEmployees +Type: query +Description: This is a GraphQL query that retrieves a list of employees. + +Input Schema: +` + bt + `json +{"additionalProperties":false,"description":"This is a GraphQL query that retrieves a list of employees.","nullable":true,"properties":{"criteria":{"additionalProperties":false,"description":"Allows to filter employees by their details.","nullable":false,"properties":{"hasPets":{"nullable":true,"type":"boolean"},"nationality":{"enum":["AMERICAN","DUTCH","ENGLISH","GERMAN","INDIAN","SPANISH","UKRAINIAN"],"nullable":true,"type":"string"},"nested":{"additionalProperties":false,"nullable":true,"properties":{"hasChildren":{"nullable":true,"type":"boolean"},"maritalStatus":{"enum":["ENGAGED","MARRIED"],"nullable":true,"type":"string"}},"type":"object"}},"type":"object"}},"type":"object"} +` + bt + ` + +GraphQL Query: +` + bt + ` +query MyEmployees($criteria: SearchInput) { + findEmployees(criteria: $criteria) { + id + isAvailable + currentMood + products + details { + forename + nationality + } + } +} +` + bt + ` + +Usage Instructions: +1. Endpoint: https://api.example.com/graphql +2. HTTP Method: POST +3. Headers Required: + - Content-Type: application/json; charset=utf-8 + +Request Format: +` + bt + `json +{ + "query": "", + "variables": +} +` + bt + ` +Important Notes: +1. Use the query string exactly as provided above +2. Do not modify or reformat the query string` assert.Equal(t, expectedContent, content.Text) }) From ef3bbc44646e44d8d7d6bd6ef5a0fe1751d84145 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 14 Apr 2026 09:45:18 +0100 Subject: [PATCH 23/46] test(router): adapt containers/image WWW-Authenticate parser --- router-tests/testutil/auth_helpers.go | 165 ++++++++++++++++++-------- 1 file changed, 116 insertions(+), 49 deletions(-) diff --git a/router-tests/testutil/auth_helpers.go b/router-tests/testutil/auth_helpers.go index 391bf0c52a..3b8cd2dcd8 100644 --- a/router-tests/testutil/auth_helpers.go +++ b/router-tests/testutil/auth_helpers.go @@ -1,63 +1,130 @@ package testutil -import ( - "strings" -) +import "strings" -// ParseWWWAuthenticateParams parses the WWW-Authenticate header from HTTP responses. -// This is a simple parser for test validation only, not production use. +// ParseWWWAuthenticateParams parses the WWW-Authenticate header from HTTP +// responses and returns its auth-params. The auth-scheme (e.g. "Bearer") is +// discarded; callers in these tests only care about the parameters. +// +// Example input: `Bearer error="insufficient_scope", scope="read write"` +// Example output: map[string]string{"error": "insufficient_scope", "scope": "read write"} // -// NOTE: LLM-generated - there are no well-established Go libraries for parsing -// WWW-Authenticate response headers (as of 2026). This parser handles the -// common case of Bearer authentication with quoted parameter values. +// The parser below is adapted from containers/image (Apache-2.0): +// https://github.com/containers/image/blob/main/docker/wwwauthenticate.go +// which itself was derived from docker/distribution. Inlined here rather +// than pulled as a dependency — it's ~50 lines and only used by tests. // -// Example input: `Bearer error="insufficient_scope", scope="read write", resource_metadata="https://example.com"` -// Example output: map[string]string{"error": "insufficient_scope", "scope": "read write", "resource_metadata": "https://example.com"} +// NOTE: Not fully RFC 7235 compliant; in particular it only handles a single +// challenge per header. Sufficient for asserting on router responses in tests. func ParseWWWAuthenticateParams(header string) map[string]string { - params := make(map[string]string) + _, params := parseValueAndParams(header) + return params +} + +type octetType byte + +const ( + isToken octetType = 1 << iota + isSpace +) + +var octetTypes [256]octetType - // Remove "Bearer " prefix (case-insensitive) - if len(header) >= 7 && strings.EqualFold(header[:7], "Bearer ") { - header = header[7:] +func init() { + for c := 0; c < 256; c++ { + var t octetType + isCtl := c <= 31 || c == 127 + isChar := c <= 127 + isSeparator := strings.ContainsRune(" \t\"(),/:;<=>?@[]\\{}", rune(c)) + if strings.ContainsRune(" \t\r\n", rune(c)) { + t |= isSpace + } + if isChar && !isCtl && !isSeparator { + t |= isToken + } + octetTypes[c] = t } - header = strings.TrimSpace(header) - - // Simple state machine to parse key="value" pairs - var key, value strings.Builder - inKey := true - inQuote := false - - for i := 0; i < len(header); i++ { - ch := header[i] - - switch { - case ch == '=' && inKey: - inKey = false - case ch == '"' && !inKey: - // Track quote state but don't add quotes to value - inQuote = !inQuote - case ch == ',' && !inQuote: - if key.Len() > 0 { - params[strings.TrimSpace(key.String())] = strings.TrimSpace(value.String()) - } - key.Reset() - value.Reset() - inKey = true - case inKey: - key.WriteByte(ch) - default: - // We're in a value (!inKey) and ch is not a quote (already handled above) - // Include everything (including spaces) when inside quotes - if inQuote || ch != ' ' || value.Len() > 0 { - value.WriteByte(ch) - } +} + +func parseValueAndParams(header string) (value string, params map[string]string) { + params = make(map[string]string) + value, s := expectToken(header) + if value == "" { + return + } + value = strings.ToLower(value) + s = "," + skipSpace(s) + for strings.HasPrefix(s, ",") { + var pkey string + pkey, s = expectToken(skipSpace(s[1:])) + if pkey == "" { + return } + if !strings.HasPrefix(s, "=") { + return + } + var pvalue string + pvalue, s = expectTokenOrQuoted(s[1:]) + if pvalue == "" { + return + } + params[strings.ToLower(pkey)] = pvalue + s = skipSpace(s) } + return +} - // Add final pair - if key.Len() > 0 { - params[strings.TrimSpace(key.String())] = strings.TrimSpace(value.String()) +func skipSpace(s string) string { + i := 0 + for ; i < len(s); i++ { + if octetTypes[s[i]]&isSpace == 0 { + break + } } + return s[i:] +} - return params +func expectToken(s string) (token, rest string) { + i := 0 + for ; i < len(s); i++ { + if octetTypes[s[i]]&isToken == 0 { + break + } + } + return s[:i], s[i:] +} + +func expectTokenOrQuoted(s string) (value, rest string) { + if !strings.HasPrefix(s, "\"") { + return expectToken(s) + } + s = s[1:] + for i := 0; i < len(s); i++ { + switch s[i] { + case '"': + return s[:i], s[i+1:] + case '\\': + p := make([]byte, len(s)-1) + j := copy(p, s[:i]) + escape := true + for i++; i < len(s); i++ { + b := s[i] + switch { + case escape: + escape = false + p[j] = b + j++ + case b == '\\': + escape = true + case b == '"': + return string(p[:j]), s[i+1:] + default: + p[j] = b + j++ + } + } + return "", "" + } + } + return "", "" } From e8491ce967a20d0fefe43a3990a4ed7d52cbbadb Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 14 Apr 2026 12:22:30 +0100 Subject: [PATCH 24/46] test(mcp): move OAuth e2e tests into protocol/ so CI picks them up --- router-tests/{ => protocol}/mcp_auth_e2e_test.go | 0 router-tests/{ => protocol}/mcp_oauth_e2e_test.go | 14 +++++++++----- router-tests/testenv/testenv.go | 6 ++++++ 3 files changed, 15 insertions(+), 5 deletions(-) rename router-tests/{ => protocol}/mcp_auth_e2e_test.go (100%) rename router-tests/{ => protocol}/mcp_oauth_e2e_test.go (95%) diff --git a/router-tests/mcp_auth_e2e_test.go b/router-tests/protocol/mcp_auth_e2e_test.go similarity index 100% rename from router-tests/mcp_auth_e2e_test.go rename to router-tests/protocol/mcp_auth_e2e_test.go diff --git a/router-tests/mcp_oauth_e2e_test.go b/router-tests/protocol/mcp_oauth_e2e_test.go similarity index 95% rename from router-tests/mcp_oauth_e2e_test.go rename to router-tests/protocol/mcp_oauth_e2e_test.go index 4020d3507b..6ac8666c12 100644 --- a/router-tests/mcp_oauth_e2e_test.go +++ b/router-tests/protocol/mcp_oauth_e2e_test.go @@ -32,7 +32,8 @@ func TestMCPOAuthInvalidToken(t *testing.T) { AuthorizationServerURL: oauthServer.Issuer(), }, }, - MCPAuthToken: validToken, + MCPAuthToken: validToken, + MCPOperationsPath: "testdata/mcp_operations", }, func(t *testing.T, xEnv *testenv.Environment) { ctx := context.Background() @@ -68,7 +69,8 @@ func TestMCPOAuthMissingToken(t *testing.T) { AuthorizationServerURL: oauthServer.Issuer(), }, }, - MCPAuthToken: validToken, + MCPAuthToken: validToken, + MCPOperationsPath: "testdata/mcp_operations", }, func(t *testing.T, xEnv *testenv.Environment) { ctx := context.Background() @@ -105,13 +107,15 @@ func TestMCPOAuthPerToolScopes(t *testing.T) { }, AuthorizationServerURL: oauthServer.Issuer(), Scopes: config.MCPOAuthScopesConfiguration{ - Initialize: []string{"mcp:connect"}, - ToolsCall: []string{"mcp:tools:write"}, + Initialize: []string{"mcp:connect"}, + GetSchema: []string{"mcp:tools:read"}, + ExecuteGraphQL: []string{"mcp:tools:write"}, }, ScopeChallengeIncludeTokenScopes: true, }, }, - MCPAuthToken: initToken, + MCPAuthToken: initToken, + MCPOperationsPath: "testdata/mcp_operations", }, func(t *testing.T, xEnv *testenv.Environment) { ctx := context.Background() diff --git a/router-tests/testenv/testenv.go b/router-tests/testenv/testenv.go index 28f25a8597..117c16b22f 100644 --- a/router-tests/testenv/testenv.go +++ b/router-tests/testenv/testenv.go @@ -690,6 +690,9 @@ func CreateTestSupervisorEnv(t testing.TB, cfg *Config) (*Environment, error) { if cfg.MCP.Enabled { cfg.MCP.Server.ListenAddr = fmt.Sprintf("localhost:%d", freeport.GetOne(t)) + if cfg.MCP.OAuth.Enabled && cfg.MCP.Server.BaseURL == "" { + cfg.MCP.Server.BaseURL = fmt.Sprintf("http://%s", cfg.MCP.Server.ListenAddr) + } } listenerAddr := fmt.Sprintf("localhost:%d", freeport.GetOne(t)) @@ -1130,6 +1133,9 @@ func CreateTestEnv(t testing.TB, cfg *Config) (*Environment, error) { if cfg.MCP.Enabled { cfg.MCP.Server.ListenAddr = fmt.Sprintf("localhost:%d", freeport.GetOne(t)) + if cfg.MCP.OAuth.Enabled && cfg.MCP.Server.BaseURL == "" { + cfg.MCP.Server.BaseURL = fmt.Sprintf("http://%s", cfg.MCP.Server.ListenAddr) + } } listenerAddr := fmt.Sprintf("localhost:%d", freeport.GetOne(t)) From e41a605e61698a8ca028bb0d46ee3b7e099fe929 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 14 Apr 2026 13:16:13 +0100 Subject: [PATCH 25/46] test(mcp): fix OAuth per-tool scope enforcement tests - Capture only 401/403 responses in authRoundTripper so later successful requests (e.g. SSE GETs) don't shadow the auth failure. - Reset lastResponse before each CallTool to prevent a prior 403 from leaking into a later call's error. - Use per-tool GetSchema and ExecuteGraphQL scope config instead of a blanket ToolsCall rule. - Rework the scope-upgrade case as a reconnect: the MCP SDK terminates the session on 403, so upgrading scopes requires a fresh Connect. --- router-tests/protocol/mcp_auth_e2e_test.go | 9 ++++++-- router-tests/protocol/mcp_oauth_e2e_test.go | 23 ++++++++++++--------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/router-tests/protocol/mcp_auth_e2e_test.go b/router-tests/protocol/mcp_auth_e2e_test.go index cdb3924ed8..09a03a0b5c 100644 --- a/router-tests/protocol/mcp_auth_e2e_test.go +++ b/router-tests/protocol/mcp_auth_e2e_test.go @@ -28,8 +28,11 @@ func (a *authRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) } resp, err := a.base.RoundTrip(req) - // Capture response for error analysis - a.lastResponse = resp + // Capture auth-error responses only; subsequent successful requests + // on the same session (e.g. SSE GETs) must not overwrite a prior 401/403. + if resp != nil && (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) { + a.lastResponse = resp + } return resp, err } @@ -116,6 +119,8 @@ func (c *MCPAuthClient) CallTool(ctx context.Context, toolName string, arguments Arguments: arguments, } + // Reset so a prior 401/403 can't shadow this call's outcome. + c.roundTripper.lastResponse = nil result, err := c.session.CallTool(ctx, params) if err != nil { if authErr := c.checkAuthError(); authErr != nil { diff --git a/router-tests/protocol/mcp_oauth_e2e_test.go b/router-tests/protocol/mcp_oauth_e2e_test.go index 6ac8666c12..0b22fa0b2e 100644 --- a/router-tests/protocol/mcp_oauth_e2e_test.go +++ b/router-tests/protocol/mcp_oauth_e2e_test.go @@ -145,7 +145,7 @@ func TestMCPOAuthPerToolScopes(t *testing.T) { require.Error(t, err, "should fail without per-tool scopes") authErr, ok := err.(*AuthError) - require.True(t, ok, "should return AuthError") + require.True(t, ok, "should return AuthError but got %T: %v", err, err) assert.Equal(t, http.StatusForbidden, authErr.StatusCode, "should return HTTP 403") assert.Equal(t, "insufficient_scope", authErr.ErrorCode) assert.Contains(t, authErr.RequiredScopes, "mcp:tools:read") @@ -188,29 +188,32 @@ func TestMCPOAuthPerToolScopes(t *testing.T) { assert.Contains(t, authErr.RequiredScopes, "mcp:tools:write") }) - t.Run("Scope upgrade on same session works", func(t *testing.T) { + t.Run("Reconnecting with upgraded scopes works", func(t *testing.T) { + // The MCP SDK closes the session on HTTP 403, so clients must + // reconnect after re-authorizing for broader scopes (per OAuth spec). readToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:connect", "mcp:tools:read"}) require.NoError(t, err) - client := NewMCPAuthClient(xEnv.GetMCPServerAddr(), readToken) - err = client.Connect(ctx) - require.NoError(t, err) - defer client.Close() //nolint:errcheck + readClient := NewMCPAuthClient(xEnv.GetMCPServerAddr(), readToken) + require.NoError(t, readClient.Connect(ctx)) - _, err = client.CallTool(ctx, "execute_graphql", map[string]any{ + _, err = readClient.CallTool(ctx, "execute_graphql", map[string]any{ "query": "query { __typename }", }) require.Error(t, err, "should fail without write scopes") + readClient.Close() //nolint:errcheck writeToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:connect", "mcp:tools:read", "mcp:tools:write"}) require.NoError(t, err) - client.SetToken(writeToken) + writeClient := NewMCPAuthClient(xEnv.GetMCPServerAddr(), writeToken) + require.NoError(t, writeClient.Connect(ctx)) + defer writeClient.Close() //nolint:errcheck - result, err := client.CallTool(ctx, "execute_graphql", map[string]any{ + result, err := writeClient.CallTool(ctx, "execute_graphql", map[string]any{ "query": "query { __typename }", }) - require.NoError(t, err, "should succeed after scope upgrade") + require.NoError(t, err, "should succeed after reconnecting with upgraded scopes") require.NotNil(t, result) }) }) From 5702537baaa2154f46b65a3eefcf625a32ccd76f Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 14 Apr 2026 13:47:34 +0100 Subject: [PATCH 26/46] test(mcp): drop per-route list from OAuthTestServer doc comment --- router-tests/testutil/oauth_server.go | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/router-tests/testutil/oauth_server.go b/router-tests/testutil/oauth_server.go index 7d37cd17e0..eceecb63f5 100644 --- a/router-tests/testutil/oauth_server.go +++ b/router-tests/testutil/oauth_server.go @@ -37,17 +37,9 @@ type authCode struct { } // OAuthTestServer is a minimal OAuth 2.1 Authorization Server for integration tests. -// -// Supported endpoints: -// - GET /.well-known/jwks.json — JWKS for token verification -// - GET /.well-known/oauth-authorization-server — AS metadata (RFC 8414) -// - POST /token — Token endpoint (client_credentials + authorization_code) -// - POST /register — Dynamic client registration (RFC 7591) -// - GET /authorize — Authorization endpoint (auto-approves for testing) -// -// The server issues real signed JWTs that can be validated by any consumer -// fetching the JWKS endpoint, making it suitable for end-to-end testing with -// the official MCP TypeScript SDK's ClientCredentialsProvider. +// It issues real signed JWTs that can be validated by any consumer fetching the +// JWKS endpoint, making it suitable for end-to-end testing with the official MCP +// TypeScript SDK's ClientCredentialsProvider. type OAuthTestServer struct { t *testing.T provider jwks.Crypto From 4514d1fb179adff29e92cbe0ae2907e2674a8f72 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 14 Apr 2026 14:30:24 +0100 Subject: [PATCH 27/46] refactor(mcp): make MaxScopeCombinations default explicit Use envDefault of 2048 directly on the config field. Drops the old DefaultMaxScopeCombinations constant and the fallback in NewScopeExtractor. Scope extraction now only runs when oauthConfig is set, since the extractor is only used by the OAuth auth middleware. --- router/pkg/config/config.go | 4 ++-- .../pkg/config/testdata/config_defaults.json | 2 +- router/pkg/config/testdata/config_full.json | 2 +- router/pkg/mcpserver/scope_extractor.go | 12 +----------- router/pkg/mcpserver/scope_extractor_test.go | 8 ++++---- router/pkg/mcpserver/server.go | 18 ++++++------------ 6 files changed, 15 insertions(+), 31 deletions(-) diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index 118e50d950..6c6088d190 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -1145,8 +1145,8 @@ type MCPOAuthConfiguration struct { ScopeChallengeIncludeTokenScopes bool `yaml:"scope_challenge_include_token_scopes" envDefault:"false" env:"SCOPE_CHALLENGE_INCLUDE_TOKEN_SCOPES"` // MaxScopeCombinations sets the upper limit on the number of OR-group combinations // produced when computing the Cartesian product of @requiresScopes across fields. - // Defaults to 2048 if not set. Increase for complex RBAC configurations. - MaxScopeCombinations int `yaml:"max_scope_combinations" envDefault:"0" env:"MAX_SCOPE_COMBINATIONS"` + // Increase for complex RBAC configurations. + MaxScopeCombinations int `yaml:"max_scope_combinations" envDefault:"2048" env:"MAX_SCOPE_COMBINATIONS"` } // MCPOAuthScopesConfiguration defines which scopes are required for different MCP operations. diff --git a/router/pkg/config/testdata/config_defaults.json b/router/pkg/config/testdata/config_defaults.json index 43f30fbe8b..97090e690f 100644 --- a/router/pkg/config/testdata/config_defaults.json +++ b/router/pkg/config/testdata/config_defaults.json @@ -178,7 +178,7 @@ "GetSchema": null }, "ScopeChallengeIncludeTokenScopes": false, - "MaxScopeCombinations": 0 + "MaxScopeCombinations": 2048 }, "ResourceDocumentation": "" }, diff --git a/router/pkg/config/testdata/config_full.json b/router/pkg/config/testdata/config_full.json index 55b1a18a24..4d2894a3ac 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -223,7 +223,7 @@ "GetSchema": null }, "ScopeChallengeIncludeTokenScopes": false, - "MaxScopeCombinations": 0 + "MaxScopeCombinations": 2048 }, "ResourceDocumentation": "" }, diff --git a/router/pkg/mcpserver/scope_extractor.go b/router/pkg/mcpserver/scope_extractor.go index 552e41283c..5d0d13163a 100644 --- a/router/pkg/mcpserver/scope_extractor.go +++ b/router/pkg/mcpserver/scope_extractor.go @@ -10,12 +10,6 @@ import ( "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" ) -// DefaultMaxScopeCombinations is the default cap on the number of OR-group -// combinations produced by the Cartesian product across scoped fields. -// This can be overridden via the mcp.oauth.scopes.max_scope_combinations -// config option for enterprises with complex RBAC configurations. -const DefaultMaxScopeCombinations = 2048 - // FieldScopeRequirement represents the scope requirement for a single field. // OrScopes is a list of AND-groups — satisfy any one group to access the field. // e.g., [["a", "b"], ["c"]] means (a AND b) OR (c) @@ -34,12 +28,8 @@ type ScopeExtractor struct { maxScopeCombinations int } -// NewScopeExtractor creates a new ScopeExtractor. If maxScopeCombinations is 0, -// DefaultMaxScopeCombinations is used. +// NewScopeExtractor creates a new ScopeExtractor. func NewScopeExtractor(fieldConfigs []*nodev1.FieldConfiguration, schemaDoc *ast.Document, maxScopeCombinations int) *ScopeExtractor { - if maxScopeCombinations <= 0 { - maxScopeCombinations = DefaultMaxScopeCombinations - } index := make(map[string][][]string) for _, fc := range fieldConfigs { authConfig := fc.GetAuthorizationConfiguration() diff --git a/router/pkg/mcpserver/scope_extractor_test.go b/router/pkg/mcpserver/scope_extractor_test.go index 97312118fa..c9b473ef65 100644 --- a/router/pkg/mcpserver/scope_extractor_test.go +++ b/router/pkg/mcpserver/scope_extractor_test.go @@ -288,7 +288,7 @@ func TestExtractScopesForOperation(t *testing.T) { opDoc, opReport := astparser.ParseGraphqlDocumentString(tt.operation) require.False(t, opReport.HasErrors(), "operation parse error: %s", opReport.Error()) - extractor := NewScopeExtractor(fieldConfigs, &schemaDoc, 0) + extractor := NewScopeExtractor(fieldConfigs, &schemaDoc, 2048) fieldReqs := extractor.ExtractScopesForOperation(&opDoc) if tt.wantNoScopes { @@ -307,7 +307,7 @@ func TestExtractScopesForOperation_FieldDetails(t *testing.T) { schemaDoc := parseTestSchema(t) - extractor := NewScopeExtractor(fieldConfigs, &schemaDoc, 0) + extractor := NewScopeExtractor(fieldConfigs, &schemaDoc, 2048) t.Run("root query field returns correct OR-of-AND scopes", func(t *testing.T) { t.Parallel() @@ -452,7 +452,7 @@ func TestComputeCombinedScopes(t *testing.T) { schemaDoc := parseTestSchema(t) - extractor := NewScopeExtractor(testFieldConfigs(), &schemaDoc, 0) + extractor := NewScopeExtractor(testFieldConfigs(), &schemaDoc, 2048) got, err := extractor.ComputeCombinedScopes(tt.fieldReqs) assert.NoError(t, err) assert.Equal(t, tt.want, got) @@ -464,7 +464,7 @@ func TestComputeCombinedScopes_ExceedsLimit(t *testing.T) { t.Parallel() schemaDoc := parseTestSchema(t) - extractor := NewScopeExtractor(testFieldConfigs(), &schemaDoc, 0) + extractor := NewScopeExtractor(testFieldConfigs(), &schemaDoc, 2048) // Build field requirements that will exceed MaxScopeCombinations (2048). // 12 fields × 2 OR-groups each = 2^12 = 4096 combinations > 2048. diff --git a/router/pkg/mcpserver/server.go b/router/pkg/mcpserver/server.go index ebce93b8dc..5be16f9432 100644 --- a/router/pkg/mcpserver/server.go +++ b/router/pkg/mcpserver/server.go @@ -531,21 +531,15 @@ func (s *GraphQLSchemaServer) Reload(schema *ast.Document, fieldConfigs []*nodev } } - // Compute per-tool scope requirements from @requiresScopes directives - var scopeExtractor *ScopeExtractor - if len(fieldConfigs) > 0 { - maxScopeCombinations := 0 - if s.oauthConfig != nil { - maxScopeCombinations = s.oauthConfig.MaxScopeCombinations - } + // Compute per-tool scope requirements from @requiresScopes directives. + // Only meaningful when OAuth is enabled; the scope extractor feeds the + // auth middleware, which is only constructed alongside oauthConfig. + if s.oauthConfig != nil && len(fieldConfigs) > 0 { + maxScopeCombinations := s.oauthConfig.MaxScopeCombinations if err := s.operationsManager.ComputeToolScopes(fieldConfigs, maxScopeCombinations); err != nil { return fmt.Errorf("failed to compute tool scopes: %w", err) } - scopeExtractor = NewScopeExtractor(fieldConfigs, schema, maxScopeCombinations) - } - - if s.authMiddleware != nil { - s.authMiddleware.SetScopeExtractor(scopeExtractor) + s.authMiddleware.SetScopeExtractor(NewScopeExtractor(fieldConfigs, schema, maxScopeCombinations)) } s.server.RemoveTools(s.registeredTools...) From bd6f1a13b464a9fa56577250c67e44aceb48b130 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 14 Apr 2026 15:22:56 +0100 Subject: [PATCH 28/46] refactor(mcp): port debug proxy from TypeScript to Go Moves the MCP debug proxy from client-tests/mcp-ts/mcp-debug-proxy.mjs to router-tests/cmd/mcp-debug-proxy, alongside the test OAuth server and JWKS helpers. Stdlib-only, uses httputil.ReverseProxy. --- client-tests/mcp-ts/mcp-debug-proxy.mjs | 90 ---------------------- router-tests/cmd/mcp-debug-proxy/main.go | 95 ++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 90 deletions(-) delete mode 100644 client-tests/mcp-ts/mcp-debug-proxy.mjs create mode 100644 router-tests/cmd/mcp-debug-proxy/main.go diff --git a/client-tests/mcp-ts/mcp-debug-proxy.mjs b/client-tests/mcp-ts/mcp-debug-proxy.mjs deleted file mode 100644 index da7c1419c2..0000000000 --- a/client-tests/mcp-ts/mcp-debug-proxy.mjs +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env node -/** - * MCP Debug Proxy — logs all requests/responses between an MCP client and the MCP server. - * Usage: node mcp-debug-proxy.mjs [listen-port] [target-port] - * Defaults: listen on 5026, forward to localhost:5025 - * - * Then update ~/.claude.json: "url": "http://localhost:5026/mcp" - */ - -import http from 'node:http'; - -const LISTEN_PORT = parseInt(process.argv[2] || '5026', 10); -const TARGET_PORT = parseInt(process.argv[3] || '5025', 10); -const TARGET_HOST = '127.0.0.1'; - -function timestamp() { - return new Date().toISOString(); -} - -function logHeaders(prefix, headers) { - for (const [k, v] of Object.entries(headers)) { - console.log(` ${prefix} ${k}: ${v}`); - } -} - -const server = http.createServer((clientReq, clientRes) => { - const id = Math.random().toString(36).slice(2, 8); - const chunks = []; - - clientReq.on('data', (chunk) => chunks.push(chunk)); - clientReq.on('end', () => { - const body = Buffer.concat(chunks).toString(); - - console.log(`\n${'='.repeat(80)}`); - console.log(`[${timestamp()}] ▶ REQUEST ${id} ${clientReq.method} ${clientReq.url}`); - logHeaders('→', clientReq.headers); - if (body) { - try { - console.log(` → BODY:`, JSON.stringify(JSON.parse(body), null, 2)); - } catch { - console.log(` → BODY:`, body.slice(0, 2000)); - } - } - - const proxyReq = http.request( - { - hostname: TARGET_HOST, - port: TARGET_PORT, - path: clientReq.url, - method: clientReq.method, - headers: { ...clientReq.headers, host: `${TARGET_HOST}:${TARGET_PORT}` }, - }, - (proxyRes) => { - const resChunks = []; - proxyRes.on('data', (chunk) => resChunks.push(chunk)); - proxyRes.on('end', () => { - const resBody = Buffer.concat(resChunks).toString(); - - console.log(`\n[${timestamp()}] ◀ RESPONSE ${id} ${proxyRes.statusCode} ${proxyRes.statusMessage}`); - logHeaders('←', proxyRes.headers); - if (resBody) { - try { - console.log(` ← BODY:`, JSON.stringify(JSON.parse(resBody), null, 2)); - } catch { - // SSE or non-JSON — print raw but truncated - console.log(` ← BODY:`, resBody.slice(0, 4000)); - } - } - console.log(`${'='.repeat(80)}\n`); - - clientRes.writeHead(proxyRes.statusCode, proxyRes.headers); - clientRes.end(Buffer.concat(resChunks)); - }); - }, - ); - - proxyReq.on('error', (err) => { - console.error(`[${timestamp()}] ✗ PROXY ERROR ${id}:`, err.message); - clientRes.writeHead(502); - clientRes.end('Bad Gateway'); - }); - - proxyReq.end(body); - }); -}); - -server.listen(LISTEN_PORT, () => { - console.log(`MCP Debug Proxy listening on :${LISTEN_PORT} → forwarding to ${TARGET_HOST}:${TARGET_PORT}`); - console.log(`Update ~/.claude.json to: "url": "http://localhost:${LISTEN_PORT}/mcp"\n`); -}); \ No newline at end of file diff --git a/router-tests/cmd/mcp-debug-proxy/main.go b/router-tests/cmd/mcp-debug-proxy/main.go new file mode 100644 index 0000000000..ed196afb5f --- /dev/null +++ b/router-tests/cmd/mcp-debug-proxy/main.go @@ -0,0 +1,95 @@ +// MCP Debug Proxy is a tiny logging reverse proxy for eyeballing traffic +// between an MCP client (Claude Desktop, Cursor, ...) and the router's MCP +// endpoint during local development. +// +// go run ./router-tests/cmd/mcp-debug-proxy -listen :5026 -target http://127.0.0.1:5025 +// +// Point the client at http://localhost:/mcp. +package main + +import ( + "bytes" + "encoding/json" + "flag" + "io" + "log" + "net/http" + "net/http/httputil" + "net/url" +) + +func main() { + listen := flag.String("listen", ":5026", "address to listen on") + target := flag.String("target", "http://127.0.0.1:5025", "upstream MCP server URL") + maxBody := flag.Int("max-body", 4096, "truncate logged bodies to N bytes") + flag.Parse() + + upstream, err := url.Parse(*target) + if err != nil { + log.Fatalf("invalid -target: %v", err) + } + + proxy := httputil.NewSingleHostReverseProxy(upstream) + proxy.ModifyResponse = func(resp *http.Response) error { + logResponse(resp, *maxBody) + return nil + } + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + log.Printf("proxy error: %v", err) + http.Error(w, "bad gateway", http.StatusBadGateway) + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + logRequest(r, *maxBody) + proxy.ServeHTTP(w, r) + }) + + log.Printf("listening on %s, forwarding to %s", *listen, upstream) + if err := http.ListenAndServe(*listen, handler); err != nil { + log.Fatal(err) + } +} + +func logRequest(r *http.Request, maxBody int) { + body, _ := io.ReadAll(r.Body) + _ = r.Body.Close() + r.Body = io.NopCloser(bytes.NewReader(body)) + + log.Printf("▶ %s %s", r.Method, r.URL.RequestURI()) + logHeaders(" →", r.Header) + if len(body) > 0 { + log.Printf(" → body: %s", formatBody(body, maxBody)) + } +} + +func logResponse(resp *http.Response, maxBody int) { + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(body)) + + log.Printf("◀ %d %s", resp.StatusCode, http.StatusText(resp.StatusCode)) + logHeaders(" ←", resp.Header) + if len(body) > 0 { + log.Printf(" ← body: %s", formatBody(body, maxBody)) + } +} + +func logHeaders(prefix string, h http.Header) { + for k, vs := range h { + for _, v := range vs { + log.Printf("%s %s: %s", prefix, k, v) + } + } +} + +func formatBody(b []byte, maxLen int) string { + out := b + var pretty bytes.Buffer + if json.Indent(&pretty, b, "", " ") == nil { + out = pretty.Bytes() + } + if len(out) > maxLen { + return string(out[:maxLen]) + "…" + } + return string(out) +} From 4a15138ab4e684259860936ab356bf6fc1a21c5a Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Tue, 14 Apr 2026 16:07:11 +0100 Subject: [PATCH 29/46] docs(mcp): move OAuth sample config alongside other router examples Renames client-tests/mcp-ts/mcp.test.config.yaml to router/mcp.oauth.config.yaml so it lives with the other example configs. Generalized the comments and marked the fields a reviewer needs to edit for their own setup. --- .../src/__tests__/McpOAuthScopes.test.ts | 6 ++-- .../mcp.oauth.config.yaml | 34 +++++++++---------- 2 files changed, 21 insertions(+), 19 deletions(-) rename client-tests/mcp-ts/mcp.test.config.yaml => router/mcp.oauth.config.yaml (54%) diff --git a/client-tests/mcp-ts/src/__tests__/McpOAuthScopes.test.ts b/client-tests/mcp-ts/src/__tests__/McpOAuthScopes.test.ts index 6694bbd12e..98e71c0e95 100644 --- a/client-tests/mcp-ts/src/__tests__/McpOAuthScopes.test.ts +++ b/client-tests/mcp-ts/src/__tests__/McpOAuthScopes.test.ts @@ -55,8 +55,10 @@ * * 1. Start the test OAuth server: * go run ./router-tests/cmd/oauth-server - * 2. Start the router with the test config (from repo root): - * go run ./router/cmd/router -config client-tests/mcp-ts/mcp.test.config.yaml + * 2. Edit router/mcp.oauth.config.yaml to point at your own execution + * config and MCP operations directory (see the EDIT ME markers), then + * start the router (from the router/ directory): + * go run ./cmd/router -config mcp.oauth.config.yaml * 3. Run the tests: * cd client-tests/mcp-ts && pnpm test */ diff --git a/client-tests/mcp-ts/mcp.test.config.yaml b/router/mcp.oauth.config.yaml similarity index 54% rename from client-tests/mcp-ts/mcp.test.config.yaml rename to router/mcp.oauth.config.yaml index 86bd4e49a7..ca74648926 100644 --- a/client-tests/mcp-ts/mcp.test.config.yaml +++ b/router/mcp.oauth.config.yaml @@ -1,16 +1,18 @@ -# MCP OAuth Scope Enforcement — Test Configuration -# yaml-language-server: $schema=../../router/pkg/config/config.schema.json +# MCP OAuth - Example Config +# yaml-language-server: $schema=./pkg/config/config.schema.json # -# This config is used by the E2E tests in McpOAuthScopes.test.ts. -# It adds built-in tool scope requirements on top of the base MCP OAuth setup. +# Example config for running the router's MCP server with OAuth 2.1 and +# per-operation scope enforcement. # -# Usage: +# Before use, edit the fields marked EDIT ME to point at your own +# execution config and MCP operations directory. +# +# Usage (from the router/ directory): # 1. Start the test OAuth server: -# go run ./router-tests/cmd/oauth-server -# 2. Start the router with this config (from the repo root): -# go run ./router/cmd/router -config client-tests/mcp-ts/mcp.test.config.yaml -# 3. Run the tests: -# cd client-tests/mcp-ts && pnpm test +# go run ../router-tests/cmd/oauth-server +# 2. Start the router with this config: +# go run ./cmd/router -config mcp.oauth.config.yaml +# 3. Point any MCP client at http://localhost:5025/mcp authentication: jwt: @@ -18,12 +20,6 @@ authentication: - url: 'http://localhost:9099/.well-known/jwks.json' refresh_interval: 1m -execution_config: - file: - path: '../router-tests/testenv/testdata/config.json' - watch: true - watch_interval: 20s - mcp: enabled: true graph_name: 'my-graph' @@ -31,6 +27,7 @@ mcp: server: listen_addr: 'localhost:5025' + # port 5026 is for the published MCP server (proxy) endpoint base_url: 'http://localhost:5026' oauth: @@ -40,6 +37,8 @@ mcp: - url: 'http://localhost:9099/.well-known/jwks.json' refresh_interval: 1m algorithms: ['RS256'] + # Include the token's existing scopes in 403 insufficient_scope challenges. + # Workaround for MCP client SDKs that replace rather than accumulate scopes. scope_challenge_include_token_scopes: true scopes: initialize: ['mcp:connect'] @@ -64,4 +63,5 @@ mcp: storage_providers: file_system: - id: 'mcp-operations' - path: './../../connectrpc-tutorial/services' + # EDIT ME: directory containing your persisted MCP operations. + path: '../connectrpc-tutorial/services' From 0ad51ed041836be59612243fa390fdbb88d9b867 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Fri, 17 Apr 2026 14:10:49 +0100 Subject: [PATCH 30/46] Update router/pkg/mcpserver/auth_middleware.go Co-authored-by: Ludwig Bedacht --- router/pkg/mcpserver/auth_middleware.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/router/pkg/mcpserver/auth_middleware.go b/router/pkg/mcpserver/auth_middleware.go index 469b4fe1af..eaf40a28b6 100644 --- a/router/pkg/mcpserver/auth_middleware.go +++ b/router/pkg/mcpserver/auth_middleware.go @@ -40,7 +40,7 @@ type MCPAuthMiddleware struct { scopes config.MCPOAuthScopesConfiguration scopeChallengeIncludeTokenScopes bool toolScopesMu sync.RWMutex - toolScopes map[string][][]string // toolName → OR-of-AND scope groups + toolScopes map[string][][]string // toolName -> OR-of-AND scope groups scopeExtractorMu sync.RWMutex scopeExtractor *ScopeExtractor } From 5a7645faca0ee16084d207a98cbf9e7087c81e9b Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Fri, 17 Apr 2026 14:11:42 +0100 Subject: [PATCH 31/46] Update router/pkg/mcpserver/auth_middleware.go Co-authored-by: Ludwig Bedacht --- router/pkg/mcpserver/auth_middleware.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/router/pkg/mcpserver/auth_middleware.go b/router/pkg/mcpserver/auth_middleware.go index eaf40a28b6..1b6d0cc806 100644 --- a/router/pkg/mcpserver/auth_middleware.go +++ b/router/pkg/mcpserver/auth_middleware.go @@ -136,8 +136,7 @@ func (m *MCPAuthMiddleware) HTTPMiddleware(next http.Handler) http.Handler { // Parse JSON-RPC body for method-level scope checks (SSE/GET requests have no body) var body []byte if r.Method == http.MethodPost && r.Body != nil { - limitedReader := io.LimitReader(r.Body, maxBodyBytes+1) - body, err = io.ReadAll(limitedReader) + body, err = io.ReadAll(io.LimitReader(r.Body, maxBodyBytes+1)) if err != nil { m.sendUnauthorizedResponse(w, fmt.Errorf("failed to read request body")) return From c07310bb20ed8b68190a1a98bf06d5a5e212d72d Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Fri, 17 Apr 2026 14:13:07 +0100 Subject: [PATCH 32/46] Update router/pkg/mcpserver/auth_middleware.go Co-authored-by: Ludwig Bedacht --- router/pkg/mcpserver/auth_middleware.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/router/pkg/mcpserver/auth_middleware.go b/router/pkg/mcpserver/auth_middleware.go index 1b6d0cc806..de6b92bef0 100644 --- a/router/pkg/mcpserver/auth_middleware.go +++ b/router/pkg/mcpserver/auth_middleware.go @@ -21,7 +21,7 @@ type contextKey string const ( userClaimsContextKey contextKey = "mcp_user_claims" // maxBodyBytes prevents memory exhaustion from oversized payloads. - maxBodyBytes int64 = 10 << 20 // 10 MB + maxBodyBytes int64 = 10 << 20 // 10 MiB ) // mcpAuthProvider adapts MCP headers to the authentication.Provider interface From f51c5de3cbe49cd94d5f7c61a493302ffda17b2e Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Mon, 20 Apr 2026 08:14:07 +0100 Subject: [PATCH 33/46] Update router-tests/testutil/oauth_server.go Co-authored-by: Ludwig Bedacht --- router-tests/testutil/oauth_server.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/router-tests/testutil/oauth_server.go b/router-tests/testutil/oauth_server.go index eceecb63f5..ac56fe9c7c 100644 --- a/router-tests/testutil/oauth_server.go +++ b/router-tests/testutil/oauth_server.go @@ -51,8 +51,8 @@ type OAuthTestServer struct { storage jwkset.Storage mu sync.RWMutex - clients map[string]*OAuthClient // clientID → client - codes map[string]*authCode // code → pending auth code + clients map[string]*OAuthClient // clientID -> client + codes map[string]*authCode // code -> pending auth code // DefaultScopes assigned to tokens when the client doesn't request specific scopes. DefaultScopes string From 66d2c7848a859c61d3485cf037619ecfa22c34ad Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Mon, 20 Apr 2026 08:20:08 +0100 Subject: [PATCH 34/46] test(router): rename mcp_auth_e2e_test.go to mcp_auth_client_test.go for clarity --- .../protocol/{mcp_auth_e2e_test.go => mcp_auth_client_test.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename router-tests/protocol/{mcp_auth_e2e_test.go => mcp_auth_client_test.go} (100%) diff --git a/router-tests/protocol/mcp_auth_e2e_test.go b/router-tests/protocol/mcp_auth_client_test.go similarity index 100% rename from router-tests/protocol/mcp_auth_e2e_test.go rename to router-tests/protocol/mcp_auth_client_test.go From 5b70af8933def21d97f4a2b25c67b561d8eae617 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Mon, 20 Apr 2026 08:23:50 +0100 Subject: [PATCH 35/46] test(router): use httptest.NewServer in OAuthTestServer to avoid freeport flakiness --- router-tests/testutil/oauth_server.go | 61 +++------------------------ 1 file changed, 7 insertions(+), 54 deletions(-) diff --git a/router-tests/testutil/oauth_server.go b/router-tests/testutil/oauth_server.go index ac56fe9c7c..1698ac5944 100644 --- a/router-tests/testutil/oauth_server.go +++ b/router-tests/testutil/oauth_server.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/http/httptest" "strings" "sync" "testing" @@ -15,7 +16,6 @@ import ( "github.com/MicahParks/jwkset" "github.com/golang-jwt/jwt/v5" - "github.com/wundergraph/cosmo/router-tests/freeport" "github.com/wundergraph/cosmo/router-tests/jwks" ) @@ -47,7 +47,7 @@ type OAuthTestServer struct { issuer string audience string jwksURL string - server *http.Server + server *httptest.Server storage jwkset.Storage mu sync.RWMutex @@ -72,9 +72,6 @@ func NewOAuthTestServer(t *testing.T, opts *OAuthTestServerOptions) (*OAuthTestS opts = &OAuthTestServerOptions{} } - port := freeport.GetOne(t) - portStr := fmt.Sprintf("%d", port) - cryptoProvider, err := jwks.NewRSACrypto("test_rsa", jwkset.AlgRS256, 2048) if err != nil { return nil, fmt.Errorf("failed to create RSA crypto: %w", err) @@ -89,15 +86,11 @@ func NewOAuthTestServer(t *testing.T, opts *OAuthTestServerOptions) (*OAuthTestS return nil, fmt.Errorf("failed to write key to storage: %w", err) } - baseURL := fmt.Sprintf("http://localhost:%s", portStr) - s := &OAuthTestServer{ t: t, provider: cryptoProvider, keyID: "test_rsa", - issuer: baseURL, audience: "test-audience", - jwksURL: baseURL + "/.well-known/jwks.json", storage: jwkStorage, clients: make(map[string]*OAuthClient), codes: make(map[string]*authCode), @@ -115,21 +108,9 @@ func NewOAuthTestServer(t *testing.T, opts *OAuthTestServerOptions) (*OAuthTestS mux.HandleFunc("/register", s.handleRegister) mux.HandleFunc("/authorize", s.handleAuthorize) - httpServer := &http.Server{ - Addr: ":" + portStr, - Handler: withCORS(mux), - } - s.server = httpServer - - go func() { - if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - t.Logf("OAuth server error: %v", err) - } - }() - - if err := s.waitForReady(5 * time.Second); err != nil { - return nil, fmt.Errorf("OAuth server failed to start: %w", err) - } + s.server = httptest.NewServer(withCORS(mux)) + s.issuer = s.server.URL + s.jwksURL = s.server.URL + "/.well-known/jwks.json" t.Logf("OAuth test server started at %s", s.issuer) return s, nil @@ -442,33 +423,6 @@ func (s *OAuthTestServer) tokenError(w http.ResponseWriter, errCode, description }) } -func (s *OAuthTestServer) waitForReady(timeout time.Duration) error { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return fmt.Errorf("timeout waiting for OAuth server") - case <-ticker.C: - req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.jwksURL, nil) - if err != nil { - continue - } - resp, err := http.DefaultClient.Do(req) - if err == nil { - resp.Body.Close() - if resp.StatusCode == http.StatusOK { - return nil - } - } - } - } -} - // --------------------------------------------------------------------------- // Public API for tests // --------------------------------------------------------------------------- @@ -528,9 +482,8 @@ func (s *OAuthTestServer) TokenEndpoint() string { return s.issuer + "/token" } // Close stops the server. func (s *OAuthTestServer) Close() error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - return s.server.Shutdown(ctx) + s.server.Close() + return nil } func randomString(nBytes int) string { From 2279b1f91a56abb6edaded01123a96ea8bb30c5c Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Mon, 20 Apr 2026 08:27:25 +0100 Subject: [PATCH 36/46] refactor(router): extract MCP server bootstrap into startMCPServer method --- router/core/router.go | 159 ++++++++++++++++++++++-------------------- 1 file changed, 84 insertions(+), 75 deletions(-) diff --git a/router/core/router.go b/router/core/router.go index 025da0bf93..3d01ba811c 100644 --- a/router/core/router.go +++ b/router/core/router.go @@ -944,81 +944,8 @@ func (r *Router) bootstrap(ctx context.Context) error { } } - if r.mcp.Enabled { - var operationsDir string - - // If storage provider ID is set, resolve it to a directory path - if r.mcp.Storage.ProviderID != "" { - r.logger.Debug("Resolving storage provider for MCP operations", - zap.String("provider_id", r.mcp.Storage.ProviderID)) - - provider, ok := r.providerRegistry.FileSystem(r.mcp.Storage.ProviderID) - if !ok { - return fmt.Errorf("storage provider with id '%s' for mcp server not found", r.mcp.Storage.ProviderID) - } - r.logger.Debug("Found file_system storage provider for MCP", - zap.String("id", provider.ID), - zap.String("path", provider.Path)) - operationsDir = provider.Path - } - - logFields := []zap.Field{ - zap.String("storage_provider_id", r.mcp.Storage.ProviderID), - } - - // Initialize the MCP server with the resolved operations directory - mcpOpts := []func(*mcpserver.Options){ - mcpserver.WithGraphName(r.mcp.GraphName), - mcpserver.WithOperationsDir(operationsDir), - mcpserver.WithListenAddr(r.mcp.Server.ListenAddr), - mcpserver.WithLogger(r.logger.With(logFields...)), - mcpserver.WithExcludeMutations(r.mcp.ExcludeMutations), - mcpserver.WithEnableArbitraryOperations(r.mcp.EnableArbitraryOperations), - mcpserver.WithExposeSchema(r.mcp.ExposeSchema), - mcpserver.WithOmitToolNamePrefix(r.mcp.OmitToolNamePrefix), - mcpserver.WithStateless(r.mcp.Session.Stateless), - } - - if r.corsOptions != nil { - mcpOpts = append(mcpOpts, mcpserver.WithCORS(*r.corsOptions)) - } - - // Add OAuth configuration if enabled - if r.mcp.OAuth.Enabled { - mcpOpts = append(mcpOpts, mcpserver.WithOAuth(&r.mcp.OAuth)) - - if r.mcp.Server.BaseURL != "" { - mcpOpts = append(mcpOpts, mcpserver.WithServerBaseURL(r.mcp.Server.BaseURL)) - } - } - - if r.mcp.ResourceDocumentation != "" { - mcpOpts = append(mcpOpts, mcpserver.WithResourceDocumentation(r.mcp.ResourceDocumentation)) - } - - mcpGraphQLEndpoint := r.graphqlEndpointURL - if r.mcp.RouterURL != "" { - mcpGraphQLEndpoint = r.mcp.RouterURL - } - - mcpss, err := mcpserver.NewGraphQLSchemaServer( - mcpGraphQLEndpoint, - mcpOpts..., - ) - if err != nil { - return fmt.Errorf("failed to create mcp server: %w", err) - } - - err = mcpss.Start() - if err != nil { - // Cleanup the server if Start() fails to prevent resource leaks - if stopErr := mcpss.Stop(ctx); stopErr != nil { - r.logger.Warn("Failed to stop MCP server during error cleanup", zap.Error(stopErr)) - } - return fmt.Errorf("failed to start MCP server: %w", err) - } - - r.mcpServer = mcpss + if err := r.startMCPServer(ctx); err != nil { + return err } if r.connectRPC.Enabled { @@ -1140,6 +1067,88 @@ func (r *Router) bootstrap(ctx context.Context) error { return nil } +// startMCPServer initializes and starts the MCP server if enabled. +func (r *Router) startMCPServer(ctx context.Context) error { + if !r.mcp.Enabled { + return nil + } + + var operationsDir string + + // If storage provider ID is set, resolve it to a directory path + if r.mcp.Storage.ProviderID != "" { + r.logger.Debug("Resolving storage provider for MCP operations", + zap.String("provider_id", r.mcp.Storage.ProviderID)) + + provider, ok := r.providerRegistry.FileSystem(r.mcp.Storage.ProviderID) + if !ok { + return fmt.Errorf("storage provider with id '%s' for mcp server not found", r.mcp.Storage.ProviderID) + } + r.logger.Debug("Found file_system storage provider for MCP", + zap.String("id", provider.ID), + zap.String("path", provider.Path)) + operationsDir = provider.Path + } + + logFields := []zap.Field{ + zap.String("storage_provider_id", r.mcp.Storage.ProviderID), + } + + // Initialize the MCP server with the resolved operations directory + mcpOpts := []func(*mcpserver.Options){ + mcpserver.WithGraphName(r.mcp.GraphName), + mcpserver.WithOperationsDir(operationsDir), + mcpserver.WithListenAddr(r.mcp.Server.ListenAddr), + mcpserver.WithLogger(r.logger.With(logFields...)), + mcpserver.WithExcludeMutations(r.mcp.ExcludeMutations), + mcpserver.WithEnableArbitraryOperations(r.mcp.EnableArbitraryOperations), + mcpserver.WithExposeSchema(r.mcp.ExposeSchema), + mcpserver.WithOmitToolNamePrefix(r.mcp.OmitToolNamePrefix), + mcpserver.WithStateless(r.mcp.Session.Stateless), + } + + if r.corsOptions != nil { + mcpOpts = append(mcpOpts, mcpserver.WithCORS(*r.corsOptions)) + } + + // Add OAuth configuration if enabled + if r.mcp.OAuth.Enabled { + mcpOpts = append(mcpOpts, mcpserver.WithOAuth(&r.mcp.OAuth)) + + if r.mcp.Server.BaseURL != "" { + mcpOpts = append(mcpOpts, mcpserver.WithServerBaseURL(r.mcp.Server.BaseURL)) + } + } + + if r.mcp.ResourceDocumentation != "" { + mcpOpts = append(mcpOpts, mcpserver.WithResourceDocumentation(r.mcp.ResourceDocumentation)) + } + + mcpGraphQLEndpoint := r.graphqlEndpointURL + if r.mcp.RouterURL != "" { + mcpGraphQLEndpoint = r.mcp.RouterURL + } + + mcpss, err := mcpserver.NewGraphQLSchemaServer( + mcpGraphQLEndpoint, + mcpOpts..., + ) + if err != nil { + return fmt.Errorf("failed to create mcp server: %w", err) + } + + if err := mcpss.Start(); err != nil { + // Cleanup the server if Start() fails to prevent resource leaks + if stopErr := mcpss.Stop(ctx); stopErr != nil { + r.logger.Warn("Failed to stop MCP server during error cleanup", zap.Error(stopErr)) + } + return fmt.Errorf("failed to start MCP server: %w", err) + } + + r.mcpServer = mcpss + return nil +} + // buildClients initializes the storage clients for persisted operations and router config. func (r *Router) buildClients(ctx context.Context) error { registry := r.providerRegistry From 5b75c378cd8dec90a9fa595e7025a5bba0e541d0 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Mon, 20 Apr 2026 08:34:49 +0100 Subject: [PATCH 37/46] fix(router): avoid leaking wrapped auth error details in WWW-Authenticate --- router/pkg/mcpserver/auth_middleware.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/router/pkg/mcpserver/auth_middleware.go b/router/pkg/mcpserver/auth_middleware.go index de6b92bef0..365dcc49d6 100644 --- a/router/pkg/mcpserver/auth_middleware.go +++ b/router/pkg/mcpserver/auth_middleware.go @@ -118,7 +118,7 @@ func (m *MCPAuthMiddleware) HTTPMiddleware(next http.Handler) http.Handler { claims, err := m.authenticator.Authenticate(r.Context(), provider) if err != nil || len(claims) == 0 { - m.sendUnauthorizedResponse(w, err) + m.sendUnauthorizedResponse(w, "invalid or missing access token") return } @@ -138,11 +138,11 @@ func (m *MCPAuthMiddleware) HTTPMiddleware(next http.Handler) http.Handler { if r.Method == http.MethodPost && r.Body != nil { body, err = io.ReadAll(io.LimitReader(r.Body, maxBodyBytes+1)) if err != nil { - m.sendUnauthorizedResponse(w, fmt.Errorf("failed to read request body")) + m.sendUnauthorizedResponse(w, "failed to read request body") return } if int64(len(body)) > maxBodyBytes { - m.sendUnauthorizedResponse(w, fmt.Errorf("request body too large")) + m.sendUnauthorizedResponse(w, "request body too large") return } r.Body = io.NopCloser(bytes.NewBuffer(body)) @@ -213,7 +213,7 @@ func (m *MCPAuthMiddleware) HTTPMiddleware(next http.Handler) http.Handler { } // sendUnauthorizedResponse sends a 401 with WWW-Authenticate per RFC 6750 and RFC 9728. -func (m *MCPAuthMiddleware) sendUnauthorizedResponse(w http.ResponseWriter, err error) { +func (m *MCPAuthMiddleware) sendUnauthorizedResponse(w http.ResponseWriter, errorDescription string) { authHeader := `Bearer realm="mcp"` if len(m.scopes.Initialize) > 0 { @@ -222,8 +222,8 @@ func (m *MCPAuthMiddleware) sendUnauthorizedResponse(w http.ResponseWriter, err if m.resourceMetadataURL != "" { authHeader += fmt.Sprintf(`, resource_metadata="%s"`, m.resourceMetadataURL) } - if err != nil { - desc := strings.ReplaceAll(err.Error(), `"`, `'`) + if errorDescription != "" { + desc := strings.ReplaceAll(errorDescription, `"`, `'`) authHeader += fmt.Sprintf(`, error_description="%s"`, desc) } From b42ee9e2cf505067e2fd0e36c7f23aa3650aa025 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Mon, 20 Apr 2026 10:58:11 +0100 Subject: [PATCH 38/46] refactor(router): unexport scope challenge helpers used only in-package --- router/pkg/mcpserver/auth_middleware.go | 8 ++++---- router/pkg/mcpserver/scope_challenge.go | 14 +++++++------- router/pkg/mcpserver/scope_challenge.md | 6 +++--- router/pkg/mcpserver/scope_challenge_test.go | 6 +++--- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/router/pkg/mcpserver/auth_middleware.go b/router/pkg/mcpserver/auth_middleware.go index 365dcc49d6..8a24fbf824 100644 --- a/router/pkg/mcpserver/auth_middleware.go +++ b/router/pkg/mcpserver/auth_middleware.go @@ -185,8 +185,8 @@ func (m *MCPAuthMiddleware) HTTPMiddleware(next http.Handler) http.Handler { // Per-tool scope check from @requiresScopes directives if toolOrScopes := m.getToolScopes(toolName); len(toolOrScopes) > 0 { - if !SatisfiesAnyGroup(tokenScopeSet, toolOrScopes) { - challengeScopes := BestScopeChallengeWithExisting(tokenScopes, toolOrScopes, m.scopeChallengeIncludeTokenScopes) + if !satisfiesAnyGroup(tokenScopeSet, toolOrScopes) { + challengeScopes := bestScopeChallengeWithExisting(tokenScopes, toolOrScopes, m.scopeChallengeIncludeTokenScopes) m.sendPerToolInsufficientScopeResponse(w, challengeScopes, toolName) return } @@ -293,11 +293,11 @@ func (m *MCPAuthMiddleware) checkExecuteGraphQLScopes(tokenScopes []string, toke return nil } - if SatisfiesAnyGroup(tokenScopeSet, combinedScopes) { + if satisfiesAnyGroup(tokenScopeSet, combinedScopes) { return nil } - return BestScopeChallengeWithExisting(tokenScopes, combinedScopes, m.scopeChallengeIncludeTokenScopes) + return bestScopeChallengeWithExisting(tokenScopes, combinedScopes, m.scopeChallengeIncludeTokenScopes) } // findMissing returns scopes from required that are not in tokenSet. diff --git a/router/pkg/mcpserver/scope_challenge.go b/router/pkg/mcpserver/scope_challenge.go index e00a960dff..6b3bd0b7bc 100644 --- a/router/pkg/mcpserver/scope_challenge.go +++ b/router/pkg/mcpserver/scope_challenge.go @@ -1,8 +1,8 @@ package mcpserver -// SatisfiesAnyGroup checks whether tokenScopeSet satisfies at least one AND-group +// satisfiesAnyGroup checks whether tokenScopeSet satisfies at least one AND-group // in the OR-of-AND scope requirements. Returns true if no requirements exist. -func SatisfiesAnyGroup(tokenScopeSet map[string]struct{}, orScopes [][]string) bool { +func satisfiesAnyGroup(tokenScopeSet map[string]struct{}, orScopes [][]string) bool { if len(orScopes) == 0 { return true } @@ -14,7 +14,7 @@ func SatisfiesAnyGroup(tokenScopeSet map[string]struct{}, orScopes [][]string) b return false } -// BestScopeChallenge picks the AND-group closest to the client's current scopes. +// bestScopeChallenge picks the AND-group closest to the client's current scopes. // Returns the complete AND-group that the client should request, or nil if any // group is already satisfied. // @@ -22,7 +22,7 @@ func SatisfiesAnyGroup(tokenScopeSet map[string]struct{}, orScopes [][]string) b // 1. For each AND-group, count how many scopes the token is missing. // 2. If any group has 0 missing, return nil (already satisfied). // 3. Pick the group with the fewest missing scopes (ties: first group wins). -func BestScopeChallenge(tokenScopes []string, combinedOrScopes [][]string) []string { +func bestScopeChallenge(tokenScopes []string, combinedOrScopes [][]string) []string { if len(combinedOrScopes) == 0 { return nil } @@ -51,12 +51,12 @@ func BestScopeChallenge(tokenScopes []string, combinedOrScopes [][]string) []str return combinedOrScopes[bestIdx] } -// BestScopeChallengeWithExisting returns the challenge scopes, optionally including +// bestScopeChallengeWithExisting returns the challenge scopes, optionally including // the token's existing scopes. When includeExisting is true, the result is the union // of the token's current scopes and the best AND-group, deduplicated. This works // around MCP client SDKs that replace rather than accumulate scopes on re-authorization. -func BestScopeChallengeWithExisting(tokenScopes []string, combinedOrScopes [][]string, includeExisting bool) []string { - best := BestScopeChallenge(tokenScopes, combinedOrScopes) +func bestScopeChallengeWithExisting(tokenScopes []string, combinedOrScopes [][]string, includeExisting bool) []string { + best := bestScopeChallenge(tokenScopes, combinedOrScopes) if best == nil { return nil } diff --git a/router/pkg/mcpserver/scope_challenge.md b/router/pkg/mcpserver/scope_challenge.md index ece7aafc91..c14968b78d 100644 --- a/router/pkg/mcpserver/scope_challenge.md +++ b/router/pkg/mcpserver/scope_challenge.md @@ -16,7 +16,7 @@ Scopes are represented as `[][]string` — a list of AND-groups where satisfying When an operation touches multiple scoped fields, their requirements are combined via Cartesian product (see `scope_extractor.go`), producing a single `[][]string` for the tool. -## Algorithm: `BestScopeChallenge` +## Algorithm: `bestScopeChallenge` **Input:** @@ -80,7 +80,7 @@ Required: [ | `["read:fact"]` | 2, 1, 2, 1 | group 2 (tie→first) | `["read:fact", "read:all"]` | | `[]` | 3, 2, 3, 1 | group 4 | `["read:all"]` | -## `BestScopeChallengeWithExisting` +## `bestScopeChallengeWithExisting` Some MCP client SDKs **replace** rather than **accumulate** scopes when re-authorizing. If the challenge only contains the scopes for the failed operation, the client loses its existing scopes. @@ -89,6 +89,6 @@ When `includeExisting` is `true`, the result is the **union** of the token's cur Example: token has `["init", "mcp:tools:write", "a"]`, best group is `["a", "b", "d"]` → result: `["init", "mcp:tools:write", "a", "b", "d"]` -## `SatisfiesAnyGroup` +## `satisfiesAnyGroup` A simple check: does the token satisfy at least one AND-group? Returns `true` if requirements are empty/nil (no scopes needed). Used as the gate check before computing a challenge. diff --git a/router/pkg/mcpserver/scope_challenge_test.go b/router/pkg/mcpserver/scope_challenge_test.go index 13d4b6c02c..689f5d10d6 100644 --- a/router/pkg/mcpserver/scope_challenge_test.go +++ b/router/pkg/mcpserver/scope_challenge_test.go @@ -221,7 +221,7 @@ func TestBestScopeChallenge(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := BestScopeChallenge(tt.tokenScopes, tt.combinedOrScopes) + got := bestScopeChallenge(tt.tokenScopes, tt.combinedOrScopes) assert.Equal(t, tt.want, got) }) } @@ -270,7 +270,7 @@ func TestBestScopeChallengeWithExisting(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := BestScopeChallengeWithExisting(tt.tokenScopes, tt.combinedOrScopes, tt.includeExisting) + got := bestScopeChallengeWithExisting(tt.tokenScopes, tt.combinedOrScopes, tt.includeExisting) assert.Equal(t, tt.want, got) }) } @@ -332,7 +332,7 @@ func TestSatisfiesAnyGroup(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := SatisfiesAnyGroup(toSet(tt.tokenScopes), tt.orScopes) + got := satisfiesAnyGroup(toSet(tt.tokenScopes), tt.orScopes) assert.Equal(t, tt.want, got) }) } From ed8b06463010bd8eaa86488ae2681c2cae1767a2 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Mon, 20 Apr 2026 11:06:46 +0100 Subject: [PATCH 39/46] test(router): use portless example URL in mcp auth test constants --- router/pkg/mcpserver/auth_middleware_test.go | 10 +++++----- router/pkg/mcpserver/execute_graphql_scope_test.go | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/router/pkg/mcpserver/auth_middleware_test.go b/router/pkg/mcpserver/auth_middleware_test.go index 97d818a7b6..cd0b25686d 100644 --- a/router/pkg/mcpserver/auth_middleware_test.go +++ b/router/pkg/mcpserver/auth_middleware_test.go @@ -52,7 +52,7 @@ func TestNewMCPAuthMiddleware(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - middleware, err := NewMCPAuthMiddleware(tt.decoder, "http://localhost:5025/.well-known/oauth-protected-resource/mcp", config.MCPOAuthScopesConfiguration{}, false) + middleware, err := NewMCPAuthMiddleware(tt.decoder, "https://test.example/.well-known/oauth-protected-resource/mcp", config.MCPOAuthScopesConfiguration{}, false) if tt.wantErr { assert.Error(t, err) assert.Nil(t, middleware) @@ -153,7 +153,7 @@ func TestExtractScopes(t *testing.T) { func TestMCPAuthMiddleware_HTTPMiddleware(t *testing.T) { t.Parallel() - const testMetadataURL = "http://localhost:5025/.well-known/oauth-protected-resource/mcp" + const testMetadataURL = "https://test.example/.well-known/oauth-protected-resource/mcp" tests := []struct { name string @@ -312,7 +312,7 @@ func TestMCPAuthMiddleware_HTTPMiddleware(t *testing.T) { func TestMCPAuthMiddleware_PerToolScopes(t *testing.T) { t.Parallel() - const testMetadataURL = "http://localhost:5025/.well-known/oauth-protected-resource/mcp" + const testMetadataURL = "https://test.example/.well-known/oauth-protected-resource/mcp" validDecoder := &mockTokenDecoder{ decodeFunc: func(token string) (authentication.Claims, error) { @@ -472,7 +472,7 @@ func TestMCPAuthMiddleware_PerToolScopes(t *testing.T) { func TestMCPAuthMiddleware_MethodLevelScopes(t *testing.T) { t.Parallel() - const testMetadataURL = "http://localhost:5025/.well-known/oauth-protected-resource/mcp" + const testMetadataURL = "https://test.example/.well-known/oauth-protected-resource/mcp" validDecoder := &mockTokenDecoder{ decodeFunc: func(token string) (authentication.Claims, error) { @@ -594,7 +594,7 @@ func TestMCPAuthMiddleware_MethodLevelScopes(t *testing.T) { func TestMCPAuthMiddleware_BuiltinToolScopes(t *testing.T) { t.Parallel() - const testMetadataURL = "http://localhost:5025/.well-known/oauth-protected-resource/mcp" + const testMetadataURL = "https://test.example/.well-known/oauth-protected-resource/mcp" validDecoder := &mockTokenDecoder{ decodeFunc: func(token string) (authentication.Claims, error) { diff --git a/router/pkg/mcpserver/execute_graphql_scope_test.go b/router/pkg/mcpserver/execute_graphql_scope_test.go index 574bbd6591..0b09efa4b6 100644 --- a/router/pkg/mcpserver/execute_graphql_scope_test.go +++ b/router/pkg/mcpserver/execute_graphql_scope_test.go @@ -16,7 +16,7 @@ import ( func TestMCPAuthMiddleware_ExecuteGraphQLScopes(t *testing.T) { t.Parallel() - const testMetadataURL = "http://localhost:5025/.well-known/oauth-protected-resource/mcp" + const testMetadataURL = "https://test.example/.well-known/oauth-protected-resource/mcp" schema := parseTestSchema(t) fieldConfigs := testFieldConfigs() @@ -172,7 +172,7 @@ func TestMCPAuthMiddleware_ExecuteGraphQLScopes(t *testing.T) { func TestMCPAuthMiddleware_ExecuteGraphQLNoExtractor(t *testing.T) { t.Parallel() - const testMetadataURL = "http://localhost:5025/.well-known/oauth-protected-resource/mcp" + const testMetadataURL = "https://test.example/.well-known/oauth-protected-resource/mcp" decoder := &mockTokenDecoder{ decodeFunc: func(token string) (authentication.Claims, error) { From 08d41cd3fbb11b53745c7d0118be24a616c527d3 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Mon, 20 Apr 2026 11:21:04 +0100 Subject: [PATCH 40/46] fix(router): propagate walker errors from scope extraction to fail closed --- router/pkg/mcpserver/auth_middleware.go | 6 +++++- router/pkg/mcpserver/operation_manager.go | 5 ++++- router/pkg/mcpserver/scope_extractor.go | 9 +++++++-- router/pkg/mcpserver/scope_extractor_test.go | 12 ++++++++---- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/router/pkg/mcpserver/auth_middleware.go b/router/pkg/mcpserver/auth_middleware.go index 8a24fbf824..6c78a9bfc2 100644 --- a/router/pkg/mcpserver/auth_middleware.go +++ b/router/pkg/mcpserver/auth_middleware.go @@ -277,7 +277,11 @@ func (m *MCPAuthMiddleware) checkExecuteGraphQLScopes(tokenScopes []string, toke return nil // let the tool handler deal with parse errors } - fieldReqs := extractor.ExtractScopesForOperation(&opDoc) + fieldReqs, err := extractor.ExtractScopesForOperation(&opDoc) + if err != nil { + // Fail closed: if we cannot determine scope requirements, treat as insufficient. + return []string{"insufficient_scope"} + } if len(fieldReqs) == 0 { return nil } diff --git a/router/pkg/mcpserver/operation_manager.go b/router/pkg/mcpserver/operation_manager.go index ea986bc5b9..85e4b3a1c7 100644 --- a/router/pkg/mcpserver/operation_manager.go +++ b/router/pkg/mcpserver/operation_manager.go @@ -93,7 +93,10 @@ func (om *OperationsManager) GetOperation(name string) *schemaloader.Operation { func (om *OperationsManager) ComputeToolScopes(fieldConfigs []*nodev1.FieldConfiguration, maxScopeCombinations int) error { extractor := NewScopeExtractor(fieldConfigs, om.schemaDoc, maxScopeCombinations) for i := range om.operations { - fieldReqs := extractor.ExtractScopesForOperation(&om.operations[i].Document) + fieldReqs, err := extractor.ExtractScopesForOperation(&om.operations[i].Document) + if err != nil { + return fmt.Errorf("tool %q: %w", om.operations[i].Name, err) + } combinedScopes, err := extractor.ComputeCombinedScopes(fieldReqs) if err != nil { return fmt.Errorf("tool %q: %w", om.operations[i].Name, err) diff --git a/router/pkg/mcpserver/scope_extractor.go b/router/pkg/mcpserver/scope_extractor.go index 5d0d13163a..ef830a77b2 100644 --- a/router/pkg/mcpserver/scope_extractor.go +++ b/router/pkg/mcpserver/scope_extractor.go @@ -55,7 +55,9 @@ func NewScopeExtractor(fieldConfigs []*nodev1.FieldConfiguration, schemaDoc *ast // ExtractScopesForOperation walks the operation's selection set and returns // per-field scope requirements for fields that have @requiresScopes. -func (e *ScopeExtractor) ExtractScopesForOperation(operation *ast.Document) []FieldScopeRequirement { +// Returns an error if the walker fails, so callers can fail closed rather than +// acting on a partially-populated result. +func (e *ScopeExtractor) ExtractScopesForOperation(operation *ast.Document) ([]FieldScopeRequirement, error) { walker := astvisitor.NewWalker(48) v := &scopeFieldVisitor{ @@ -69,8 +71,11 @@ func (e *ScopeExtractor) ExtractScopesForOperation(operation *ast.Document) []Fi report := &operationreport.Report{} walker.Walk(operation, e.schemaDoc, report) + if report.HasErrors() { + return nil, fmt.Errorf("scope extraction walker failed: %w", report) + } - return v.results + return v.results, nil } // ComputeCombinedScopes computes the Cartesian product of OR-groups across fields, diff --git a/router/pkg/mcpserver/scope_extractor_test.go b/router/pkg/mcpserver/scope_extractor_test.go index c9b473ef65..3b961910e3 100644 --- a/router/pkg/mcpserver/scope_extractor_test.go +++ b/router/pkg/mcpserver/scope_extractor_test.go @@ -289,7 +289,8 @@ func TestExtractScopesForOperation(t *testing.T) { require.False(t, opReport.HasErrors(), "operation parse error: %s", opReport.Error()) extractor := NewScopeExtractor(fieldConfigs, &schemaDoc, 2048) - fieldReqs := extractor.ExtractScopesForOperation(&opDoc) + fieldReqs, err := extractor.ExtractScopesForOperation(&opDoc) + require.NoError(t, err) if tt.wantNoScopes { assert.Empty(t, fieldReqs, "expected no scoped fields") @@ -319,7 +320,8 @@ func TestExtractScopesForOperation_FieldDetails(t *testing.T) { }`) require.False(t, report.HasErrors()) - fieldReqs := extractor.ExtractScopesForOperation(&opDoc) + fieldReqs, err := extractor.ExtractScopesForOperation(&opDoc) + require.NoError(t, err) require.Len(t, fieldReqs, 1) assert.Equal(t, "Query", fieldReqs[0].TypeName) assert.Equal(t, "topSecretFederationFacts", fieldReqs[0].FieldName) @@ -336,7 +338,8 @@ func TestExtractScopesForOperation_FieldDetails(t *testing.T) { }`) require.False(t, report.HasErrors()) - fieldReqs := extractor.ExtractScopesForOperation(&opDoc) + fieldReqs, err := extractor.ExtractScopesForOperation(&opDoc) + require.NoError(t, err) require.Len(t, fieldReqs, 1) assert.Equal(t, "Employee", fieldReqs[0].TypeName) assert.Equal(t, "startDate", fieldReqs[0].FieldName) @@ -353,7 +356,8 @@ func TestExtractScopesForOperation_FieldDetails(t *testing.T) { }`) require.False(t, report.HasErrors()) - fieldReqs := extractor.ExtractScopesForOperation(&opDoc) + fieldReqs, err := extractor.ExtractScopesForOperation(&opDoc) + require.NoError(t, err) require.Len(t, fieldReqs, 1) assert.Equal(t, "Mutation", fieldReqs[0].TypeName) assert.Equal(t, "addFact", fieldReqs[0].FieldName) From 9e1b533cf34bbd0c48d5ba939050805a74871b60 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Mon, 20 Apr 2026 11:27:45 +0100 Subject: [PATCH 41/46] test(router): document cross-product edge cases for empty OR and AND groups --- router/pkg/mcpserver/scope_extractor.go | 3 +++ router/pkg/mcpserver/scope_extractor_test.go | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/router/pkg/mcpserver/scope_extractor.go b/router/pkg/mcpserver/scope_extractor.go index ef830a77b2..a855abbea9 100644 --- a/router/pkg/mcpserver/scope_extractor.go +++ b/router/pkg/mcpserver/scope_extractor.go @@ -143,6 +143,9 @@ func (v *scopeFieldVisitor) EnterField(ref int) { // crossProduct computes the Cartesian product of two sets of OR-groups, // merging AND-scopes within each combination and deduplicating. // Returns an error if the resulting number of combinations would exceed the limit. +// +// Callers pass non-empty inputs in practice. If `a` is empty (no OR branches), +// the result is empty, meaning nothing grants access. func crossProduct(a, b [][]string, maxCombinations int) ([][]string, error) { total := len(a) * len(b) if total > maxCombinations { diff --git a/router/pkg/mcpserver/scope_extractor_test.go b/router/pkg/mcpserver/scope_extractor_test.go index 3b961910e3..a3000db84d 100644 --- a/router/pkg/mcpserver/scope_extractor_test.go +++ b/router/pkg/mcpserver/scope_extractor_test.go @@ -486,3 +486,21 @@ func TestComputeCombinedScopes_ExceedsLimit(t *testing.T) { assert.Nil(t, got) assert.Contains(t, err.Error(), "scope combination limit") } + +func TestCrossProduct_EdgeCases(t *testing.T) { + t.Parallel() + + t.Run("empty OR list means no way to pass, result is empty", func(t *testing.T) { + t.Parallel() + got, err := crossProduct([][]string{}, [][]string{{"x"}}, 100) + require.NoError(t, err) + assert.Empty(t, got) + }) + + t.Run("empty AND group means no scopes required, merges to just the other side", func(t *testing.T) { + t.Parallel() + got, err := crossProduct([][]string{{}}, [][]string{{"x"}}, 100) + require.NoError(t, err) + assert.Equal(t, [][]string{{"x"}}, got) + }) +} From 732b5d1c43472996f82eca5a2e148f56d8a2966a Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Mon, 20 Apr 2026 11:58:33 +0100 Subject: [PATCH 42/46] refactor(router): derive mcp server context from caller, drop unused ctx field --- router/core/router.go | 1 + router/pkg/mcpserver/server.go | 7 ++----- router/pkg/mcpserver/server_test.go | 3 +++ 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/router/core/router.go b/router/core/router.go index 3d01ba811c..cb173417d3 100644 --- a/router/core/router.go +++ b/router/core/router.go @@ -1130,6 +1130,7 @@ func (r *Router) startMCPServer(ctx context.Context) error { } mcpss, err := mcpserver.NewGraphQLSchemaServer( + ctx, mcpGraphQLEndpoint, mcpOpts..., ) diff --git a/router/pkg/mcpserver/server.go b/router/pkg/mcpserver/server.go index 5be16f9432..40aedda1ac 100644 --- a/router/pkg/mcpserver/server.go +++ b/router/pkg/mcpserver/server.go @@ -115,7 +115,6 @@ type GraphQLSchemaServer struct { schemaCompiler *SchemaCompiler registeredTools []string corsConfig cors.Config - ctx context.Context cancel context.CancelFunc oauthConfig *config.MCPOAuthConfiguration serverBaseURL string @@ -193,7 +192,7 @@ type GraphQLResponse struct { } // NewGraphQLSchemaServer creates a new GraphQL schema server -func NewGraphQLSchemaServer(routerGraphQLEndpoint string, opts ...func(*Options)) (*GraphQLSchemaServer, error) { +func NewGraphQLSchemaServer(ctx context.Context, routerGraphQLEndpoint string, opts ...func(*Options)) (*GraphQLSchemaServer, error) { if routerGraphQLEndpoint == "" { return nil, fmt.Errorf("routerGraphQLEndpoint cannot be empty") } @@ -219,8 +218,7 @@ func NewGraphQLSchemaServer(routerGraphQLEndpoint string, opts ...func(*Options) opt(options) } - // Create a cancellable context for managing the server lifecycle - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(ctx) var authMiddleware *MCPAuthMiddleware if options.OAuthConfig != nil && options.OAuthConfig.Enabled { @@ -316,7 +314,6 @@ func NewGraphQLSchemaServer(routerGraphQLEndpoint string, opts ...func(*Options) omitToolNamePrefix: options.OmitToolNamePrefix, stateless: options.Stateless, corsConfig: options.CorsConfig, - ctx: ctx, cancel: cancel, oauthConfig: options.OAuthConfig, serverBaseURL: options.ServerBaseURL, diff --git a/router/pkg/mcpserver/server_test.go b/router/pkg/mcpserver/server_test.go index 9f1e4a3366..3a3c044609 100644 --- a/router/pkg/mcpserver/server_test.go +++ b/router/pkg/mcpserver/server_test.go @@ -81,6 +81,7 @@ func TestReload_NoToolDuplication(t *testing.T) { require.NoError(t, err) srv, err := NewGraphQLSchemaServer( + t.Context(), "http://localhost:4000/graphql", WithLogger(logger), WithOperationsDir(tempDir), @@ -127,6 +128,7 @@ func TestReload_ReservedToolNameCollision(t *testing.T) { require.NoError(t, err) srv, err := NewGraphQLSchemaServer( + t.Context(), "http://localhost:4000/graphql", WithLogger(logger), WithOperationsDir(tempDir), @@ -170,6 +172,7 @@ func TestReload_PrefixModeAvoidsReservedNameCollision(t *testing.T) { require.NoError(t, err) srv, err := NewGraphQLSchemaServer( + t.Context(), "http://localhost:4000/graphql", WithLogger(logger), WithOperationsDir(tempDir), From 7dd0a38fe280f7b1ad5e41868f3e722f310dd18e Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Mon, 20 Apr 2026 12:42:41 +0100 Subject: [PATCH 43/46] fix(router): guard validation error causes access against empty slice --- router/pkg/mcpserver/schema_compiler.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/router/pkg/mcpserver/schema_compiler.go b/router/pkg/mcpserver/schema_compiler.go index 11816bd196..8ec3412447 100644 --- a/router/pkg/mcpserver/schema_compiler.go +++ b/router/pkg/mcpserver/schema_compiler.go @@ -70,7 +70,10 @@ func (sc *SchemaCompiler) ValidateInput(data []byte, compiledSchema *jsonschema. if err := compiledSchema.Validate(v); err != nil { var validationErr *jsonschema.ValidationError if errors.As(err, &validationErr) { - return fmt.Errorf("validation error: %s", validationErr.Causes[0].Error()) + if len(validationErr.Causes) > 0 { + return fmt.Errorf("validation error: %s", validationErr.Causes[0].Error()) + } + return fmt.Errorf("validation error: %s", validationErr.Error()) } return fmt.Errorf("schema validation failed: %w", err) } From 1cf4c29785a590b858cf7616e5d3b305e9db7851 Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Mon, 20 Apr 2026 12:42:43 +0100 Subject: [PATCH 44/46] refactor(router): use raw string literal for operation info error --- router/pkg/mcpserver/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/router/pkg/mcpserver/server.go b/router/pkg/mcpserver/server.go index 40aedda1ac..a3c5c36858 100644 --- a/router/pkg/mcpserver/server.go +++ b/router/pkg/mcpserver/server.go @@ -799,7 +799,7 @@ func (s *GraphQLSchemaServer) handleGraphQLOperationInfo() func(ctx context.Cont var input GraphQLOperationInfoInput inputBytes := request.Params.Arguments if err := json.Unmarshal(inputBytes, &input); err != nil { - return nil, fmt.Errorf("failed to unmarshal input arguments: %w. Ensure you provide {\"operationName\": \"\"}", err) + return nil, fmt.Errorf(`failed to unmarshal input arguments: %w. Ensure you provide {"operationName": ""}`, err) } if input.OperationName == "" { From 387979b52da78948e4e22d94da97961b022c949f Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Mon, 20 Apr 2026 13:16:32 +0100 Subject: [PATCH 45/46] refactor(router): rename mcp oauth tests to ACE sentence pattern --- router-tests/protocol/mcp_oauth_e2e_test.go | 73 ++++++------------- .../mcpserver/execute_graphql_scope_test.go | 26 +++---- 2 files changed, 37 insertions(+), 62 deletions(-) diff --git a/router-tests/protocol/mcp_oauth_e2e_test.go b/router-tests/protocol/mcp_oauth_e2e_test.go index 0b22fa0b2e..2503061f86 100644 --- a/router-tests/protocol/mcp_oauth_e2e_test.go +++ b/router-tests/protocol/mcp_oauth_e2e_test.go @@ -12,8 +12,7 @@ import ( "github.com/wundergraph/cosmo/router/pkg/config" ) -// TestMCPOAuthInvalidToken tests that invalid JWT tokens are rejected with HTTP 401. -func TestMCPOAuthInvalidToken(t *testing.T) { +func TestMCPOAuthAuthentication(t *testing.T) { oauthServer, err := testutil.NewOAuthTestServer(t, nil) require.NoError(t, err, "failed to start OAuth server") defer oauthServer.Close() //nolint:errcheck @@ -37,56 +36,32 @@ func TestMCPOAuthInvalidToken(t *testing.T) { }, func(t *testing.T, xEnv *testenv.Environment) { ctx := context.Background() - client := NewMCPAuthClient(xEnv.GetMCPServerAddr(), "invalid-jwt-token") + t.Run("returns 401 with resource metadata when token is invalid", func(t *testing.T) { + client := NewMCPAuthClient(xEnv.GetMCPServerAddr(), "invalid-jwt-token") - err := client.Connect(ctx) - require.Error(t, err, "should fail to connect with invalid token") + err := client.Connect(ctx) + require.Error(t, err, "should fail to connect with invalid token") - authErr, ok := err.(*AuthError) - require.True(t, ok, "expected *AuthError but got %T: %v", err, err) - assert.Equal(t, http.StatusUnauthorized, authErr.StatusCode, "should return HTTP 401") - assert.NotEmpty(t, authErr.ResourceMetadataURL, "should include resource_metadata for OAuth discovery") - }) -} - -// TestMCPOAuthMissingToken tests that missing Authorization header is rejected. -func TestMCPOAuthMissingToken(t *testing.T) { - oauthServer, err := testutil.NewOAuthTestServer(t, nil) - require.NoError(t, err, "failed to start OAuth server") - defer oauthServer.Close() //nolint:errcheck - - validToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:tools:read"}) - require.NoError(t, err, "failed to create valid token") - - testenv.Run(t, &testenv.Config{ - MCP: config.MCPConfiguration{ - Enabled: true, - OAuth: config.MCPOAuthConfiguration{ - Enabled: true, - JWKS: []config.JWKSConfiguration{ - {URL: oauthServer.JWKSURL()}, - }, - AuthorizationServerURL: oauthServer.Issuer(), - }, - }, - MCPAuthToken: validToken, - MCPOperationsPath: "testdata/mcp_operations", - }, func(t *testing.T, xEnv *testenv.Environment) { - ctx := context.Background() + authErr, ok := err.(*AuthError) + require.True(t, ok, "expected *AuthError but got %T: %v", err, err) + assert.Equal(t, http.StatusUnauthorized, authErr.StatusCode, "should return HTTP 401") + assert.NotEmpty(t, authErr.ResourceMetadataURL, "should include resource_metadata for OAuth discovery") + }) - client := NewMCPAuthClient(xEnv.GetMCPServerAddr(), "") + t.Run("returns 401 with resource metadata when token is missing", func(t *testing.T) { + client := NewMCPAuthClient(xEnv.GetMCPServerAddr(), "") - err := client.Connect(ctx) - require.Error(t, err, "should fail to connect without token") + err := client.Connect(ctx) + require.Error(t, err, "should fail to connect without token") - authErr, ok := err.(*AuthError) - require.True(t, ok, "expected *AuthError but got %T: %v", err, err) - assert.Equal(t, http.StatusUnauthorized, authErr.StatusCode, "should return HTTP 401") - assert.NotEmpty(t, authErr.ResourceMetadataURL, "should include resource_metadata for OAuth discovery") + authErr, ok := err.(*AuthError) + require.True(t, ok, "expected *AuthError but got %T: %v", err, err) + assert.Equal(t, http.StatusUnauthorized, authErr.StatusCode, "should return HTTP 401") + assert.NotEmpty(t, authErr.ResourceMetadataURL, "should include resource_metadata for OAuth discovery") + }) }) } -// TestMCPOAuthPerToolScopes tests per-tool scope requirements. func TestMCPOAuthPerToolScopes(t *testing.T) { oauthServer, err := testutil.NewOAuthTestServer(t, nil) require.NoError(t, err, "failed to start OAuth server") @@ -119,7 +94,7 @@ func TestMCPOAuthPerToolScopes(t *testing.T) { }, func(t *testing.T, xEnv *testenv.Environment) { ctx := context.Background() - t.Run("HTTP-level scopes are enforced on all requests", func(t *testing.T) { + t.Run("returns error when token is missing HTTP-level scopes", func(t *testing.T) { noConnectToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:tools:read"}) require.NoError(t, err) @@ -132,7 +107,7 @@ func TestMCPOAuthPerToolScopes(t *testing.T) { assert.True(t, authErr.StatusCode == http.StatusUnauthorized || authErr.StatusCode == http.StatusForbidden) }) - t.Run("Per-tool scopes are enforced on tool calls", func(t *testing.T) { + t.Run("returns 403 with insufficient_scope when tool call is missing per-tool scopes", func(t *testing.T) { connectOnlyToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:connect"}) require.NoError(t, err) @@ -151,7 +126,7 @@ func TestMCPOAuthPerToolScopes(t *testing.T) { assert.Contains(t, authErr.RequiredScopes, "mcp:tools:read") }) - t.Run("Token with correct per-tool scopes succeeds", func(t *testing.T) { + t.Run("allows tool call when token has required per-tool scope", func(t *testing.T) { readToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:connect", "mcp:tools:read"}) require.NoError(t, err) @@ -165,7 +140,7 @@ func TestMCPOAuthPerToolScopes(t *testing.T) { require.NotNil(t, result) }) - t.Run("Different tools require different scopes", func(t *testing.T) { + t.Run("challenges with different scopes for different tools", func(t *testing.T) { readToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:connect", "mcp:tools:read"}) require.NoError(t, err) @@ -188,7 +163,7 @@ func TestMCPOAuthPerToolScopes(t *testing.T) { assert.Contains(t, authErr.RequiredScopes, "mcp:tools:write") }) - t.Run("Reconnecting with upgraded scopes works", func(t *testing.T) { + t.Run("allows tool call after reconnecting with upgraded scopes", func(t *testing.T) { // The MCP SDK closes the session on HTTP 403, so clients must // reconnect after re-authorizing for broader scopes (per OAuth spec). readToken, err := oauthServer.CreateTokenWithScopes("test-user", []string{"mcp:connect", "mcp:tools:read"}) diff --git a/router/pkg/mcpserver/execute_graphql_scope_test.go b/router/pkg/mcpserver/execute_graphql_scope_test.go index 0b09efa4b6..3f39e6f216 100644 --- a/router/pkg/mcpserver/execute_graphql_scope_test.go +++ b/router/pkg/mcpserver/execute_graphql_scope_test.go @@ -13,7 +13,7 @@ import ( "github.com/wundergraph/cosmo/router/pkg/config" ) -func TestMCPAuthMiddleware_ExecuteGraphQLScopes(t *testing.T) { +func TestMCPAuthMiddlewareExecuteGraphQLScopes(t *testing.T) { t.Parallel() const testMetadataURL = "https://test.example/.well-known/oauth-protected-resource/mcp" @@ -64,25 +64,25 @@ func TestMCPAuthMiddleware_ExecuteGraphQLScopes(t *testing.T) { wantContains string }{ { - name: "unscoped query passes", + name: "allows request when query has no scoped fields", token: "no-extra-scopes", query: `query { employees { id tag } }`, wantStatusCode: 200, }, { - name: "scoped query with matching scope passes", + name: "allows request when token has required scope", token: "has-read-fact", query: `query { topSecretFederationFacts { ... on DirectiveFact { title } } }`, wantStatusCode: 200, }, { - name: "scoped query with alternative scope (read:all) passes", + name: "allows request when token has alternative OR scope", token: "has-read-all", query: `query { topSecretFederationFacts { ... on DirectiveFact { title } } }`, wantStatusCode: 200, }, { - name: "scoped query without required scope returns 403", + name: "returns 403 with scope challenge when token is missing required scope", token: "no-extra-scopes", query: `query { topSecretFederationFacts { ... on DirectiveFact { title } } }`, wantStatusCode: 403, @@ -90,26 +90,26 @@ func TestMCPAuthMiddleware_ExecuteGraphQLScopes(t *testing.T) { wantContains: `error_description="insufficient scopes for tool execute_graphql"`, }, { - name: "AND scopes - token has one of two required", + name: "returns 403 when token has only one scope from an AND group", token: "has-read-employee", query: `query { employee(id: 1) { id startDate } }`, wantStatusCode: 403, wantScope: `scope="read:employee read:private"`, }, { - name: "AND scopes - token satisfies full AND group", + name: "allows request when token satisfies full AND group", token: "has-read-employee-private", query: `query { employee(id: 1) { id startDate } }`, wantStatusCode: 200, }, { - name: "AND scopes - read:all satisfies alternative group", + name: "allows request when token has scope from alternative OR group", token: "has-read-all", query: `query { employee(id: 1) { id startDate } }`, wantStatusCode: 200, }, { - name: "empty relevant scopes picks smallest group", + name: "picks smallest missing group for challenge when token has no relevant scopes", token: "no-extra-scopes", query: `query { employee(id: 1) { id startDate } }`, wantStatusCode: 403, @@ -117,7 +117,7 @@ func TestMCPAuthMiddleware_ExecuteGraphQLScopes(t *testing.T) { wantScope: `scope="read:all"`, }, { - name: "include token scopes in challenge", + name: "includes token scopes in challenge when configured", token: "has-mcp-connect", query: `query { topSecretFederationFacts { ... on DirectiveFact { title } } }`, scopeChallengeIncludeTokenScopes: true, @@ -125,13 +125,13 @@ func TestMCPAuthMiddleware_ExecuteGraphQLScopes(t *testing.T) { wantScope: `scope="mcp:connect mcp:tools:write read:fact"`, }, { - name: "invalid query passes through (not scope-checked)", + name: "allows request through when query fails to parse", token: "no-extra-scopes", query: `not a valid query {}`, wantStatusCode: 200, }, { - name: "empty query passes through", + name: "allows request through when query is empty", token: "no-extra-scopes", query: ``, wantStatusCode: 200, @@ -169,7 +169,7 @@ func TestMCPAuthMiddleware_ExecuteGraphQLScopes(t *testing.T) { } } -func TestMCPAuthMiddleware_ExecuteGraphQLNoExtractor(t *testing.T) { +func TestMCPAuthMiddlewareExecuteGraphQLNoExtractor(t *testing.T) { t.Parallel() const testMetadataURL = "https://test.example/.well-known/oauth-protected-resource/mcp" From 99839a85e0e0acf0eaf869a39d1f44220cd6838b Mon Sep 17 00:00:00 2001 From: Ahmet Soormally Date: Mon, 20 Apr 2026 13:25:33 +0100 Subject: [PATCH 46/46] refactor(router): rename mcp unit tests to ACE sentence pattern --- router/pkg/mcpserver/auth_middleware_test.go | 88 +++++++++---------- router/pkg/mcpserver/scope_challenge_test.go | 70 +++++++-------- router/pkg/mcpserver/scope_extractor_test.go | 92 ++++++++++---------- 3 files changed, 124 insertions(+), 126 deletions(-) diff --git a/router/pkg/mcpserver/auth_middleware_test.go b/router/pkg/mcpserver/auth_middleware_test.go index cd0b25686d..b6591418ab 100644 --- a/router/pkg/mcpserver/auth_middleware_test.go +++ b/router/pkg/mcpserver/auth_middleware_test.go @@ -39,12 +39,12 @@ func TestNewMCPAuthMiddleware(t *testing.T) { wantErr bool }{ { - name: "valid decoder", + name: "returns middleware when decoder is valid", decoder: validDecoder, wantErr: false, }, { - name: "nil decoder", + name: "returns error when decoder is nil", decoder: nil, wantErr: true, }, @@ -74,7 +74,7 @@ func TestGetClaimsFromContext(t *testing.T) { wantClaims authentication.Claims }{ { - name: "claims present", + name: "returns claims when present in context", setupCtx: func() context.Context { return context.WithValue(context.Background(), userClaimsContextKey, expectedClaims) }, @@ -82,7 +82,7 @@ func TestGetClaimsFromContext(t *testing.T) { wantClaims: expectedClaims, }, { - name: "claims absent", + name: "returns false when claims are absent from context", setupCtx: func() context.Context { return context.Background() }, @@ -90,7 +90,7 @@ func TestGetClaimsFromContext(t *testing.T) { wantClaims: nil, }, { - name: "wrong type", + name: "returns false when context value has wrong type", setupCtx: func() context.Context { return context.WithValue(context.Background(), userClaimsContextKey, "not-claims") }, @@ -115,26 +115,26 @@ func TestExtractScopes(t *testing.T) { want []string }{ { - name: "scope with multiple values", + name: "splits scope claim into multiple values", claims: authentication.Claims{ "scope": "mcp:tools mcp:read mcp:write", }, want: []string{"mcp:tools", "mcp:read", "mcp:write"}, }, { - name: "scope with single value", + name: "returns single value for single-scope claim", claims: authentication.Claims{ "scope": "mcp:tools", }, want: []string{"mcp:tools"}, }, { - name: "no scope claim", + name: "returns nil when scope claim is missing", claims: authentication.Claims{}, want: nil, }, { - name: "empty scope string", + name: "returns empty slice when scope claim is empty string", claims: authentication.Claims{ "scope": "", }, @@ -150,7 +150,7 @@ func TestExtractScopes(t *testing.T) { } } -func TestMCPAuthMiddleware_HTTPMiddleware(t *testing.T) { +func TestMCPAuthMiddlewareHTTP(t *testing.T) { t.Parallel() const testMetadataURL = "https://test.example/.well-known/oauth-protected-resource/mcp" @@ -164,7 +164,7 @@ func TestMCPAuthMiddleware_HTTPMiddleware(t *testing.T) { wantWWWAuthenticatePrefix string }{ { - name: "valid token without scopes", + name: "allows request with valid token when no scopes are configured", scopes: config.MCPOAuthScopesConfiguration{}, setupDecoder: func() *mockTokenDecoder { return &mockTokenDecoder{ @@ -184,7 +184,7 @@ func TestMCPAuthMiddleware_HTTPMiddleware(t *testing.T) { wantStatusCode: 200, }, { - name: "missing auth header - 401 includes init scopes", + name: "returns 401 with init scopes in challenge when auth header is missing", scopes: config.MCPOAuthScopesConfiguration{Initialize: []string{"mcp:connect"}}, setupDecoder: func() *mockTokenDecoder { return &mockTokenDecoder{ @@ -201,7 +201,7 @@ func TestMCPAuthMiddleware_HTTPMiddleware(t *testing.T) { wantWWWAuthenticatePrefix: `Bearer realm="mcp", scope="mcp:connect", resource_metadata="` + testMetadataURL + `"`, }, { - name: "missing auth header - 401 without scopes when none configured", + name: "returns 401 without scope challenge when no scopes are configured", scopes: config.MCPOAuthScopesConfiguration{}, setupDecoder: func() *mockTokenDecoder { return &mockTokenDecoder{ @@ -218,7 +218,7 @@ func TestMCPAuthMiddleware_HTTPMiddleware(t *testing.T) { wantWWWAuthenticatePrefix: `Bearer realm="mcp", resource_metadata="` + testMetadataURL + `"`, }, { - name: "invalid token - 401 includes init scopes", + name: "returns 401 with init scopes in challenge when token is invalid", scopes: config.MCPOAuthScopesConfiguration{Initialize: []string{"mcp:connect"}}, setupDecoder: func() *mockTokenDecoder { return &mockTokenDecoder{ @@ -236,7 +236,7 @@ func TestMCPAuthMiddleware_HTTPMiddleware(t *testing.T) { wantWWWAuthenticatePrefix: `Bearer realm="mcp", scope="mcp:connect", resource_metadata="` + testMetadataURL + `"`, }, { - name: "insufficient init scopes - 403 with include token scopes enabled", + name: "returns 403 with token scopes in challenge when init scopes are insufficient", scopes: config.MCPOAuthScopesConfiguration{Initialize: []string{"mcp:connect"}}, setupDecoder: func() *mockTokenDecoder { return &mockTokenDecoder{ @@ -260,7 +260,7 @@ func TestMCPAuthMiddleware_HTTPMiddleware(t *testing.T) { wantWWWAuthenticatePrefix: `Bearer error="insufficient_scope", scope="mcp:tools:read mcp:connect"`, }, { - name: "valid token with all required scopes", + name: "allows request when token has all required scopes", scopes: config.MCPOAuthScopesConfiguration{ Initialize: []string{"mcp:connect"}, }, @@ -309,7 +309,7 @@ func TestMCPAuthMiddleware_HTTPMiddleware(t *testing.T) { } } -func TestMCPAuthMiddleware_PerToolScopes(t *testing.T) { +func TestMCPAuthMiddlewarePerToolScopes(t *testing.T) { t.Parallel() const testMetadataURL = "https://test.example/.well-known/oauth-protected-resource/mcp" @@ -361,25 +361,25 @@ func TestMCPAuthMiddleware_PerToolScopes(t *testing.T) { wantContains string // additional WWW-Authenticate check }{ { - name: "unscoped tool passes with just static scopes", + name: "allows tool call when tool has no per-tool scopes configured", token: "no-scopes", body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_list_employees"}}`, wantStatusCode: 200, }, { - name: "scoped tool - token has matching scope (read:fact)", + name: "allows tool call when token has required per-tool scope", token: "has-read-fact", body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_top_secret_facts"}}`, wantStatusCode: 200, }, { - name: "scoped tool - token has matching scope (read:all)", + name: "allows tool call when token has alternative per-tool scope", token: "has-read-all", body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_top_secret_facts"}}`, wantStatusCode: 200, }, { - name: "scoped tool - token lacks scopes, challenge picks smallest group", + name: "returns 403 with smallest group as challenge when token lacks per-tool scopes", token: "no-scopes", body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_top_secret_facts"}}`, wantStatusCode: 403, @@ -387,7 +387,7 @@ func TestMCPAuthMiddleware_PerToolScopes(t *testing.T) { wantContains: `error_description="insufficient scopes for tool execute_operation_get_top_secret_facts"`, }, { - name: "scoped tool - include token scopes in challenge", + name: "includes token scopes in per-tool challenge when configured", token: "no-scopes", body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_top_secret_facts"}}`, scopeChallengeIncludeTokenScopes: true, @@ -395,7 +395,7 @@ func TestMCPAuthMiddleware_PerToolScopes(t *testing.T) { wantScope: `scope="mcp:connect mcp:tools:write read:fact"`, }, { - name: "AND group - token has one of two required, challenge picks closest group", + name: "returns 403 with closest group as challenge when token has only one scope from an AND group", token: "has-read-employee", body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_employee_start_date"}}`, wantStatusCode: 403, @@ -405,19 +405,19 @@ func TestMCPAuthMiddleware_PerToolScopes(t *testing.T) { wantScope: `scope="read:employee read:private"`, }, { - name: "AND group - token satisfies full AND group", + name: "allows tool call when token satisfies full AND group", token: "has-read-employee-private", body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_employee_start_date"}}`, wantStatusCode: 200, }, { - name: "AND group - token has read:all satisfies second OR group", + name: "allows tool call when token has scope from alternative OR group", token: "has-read-all", body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_employee_start_date"}}`, wantStatusCode: 200, }, { - name: "AND group - empty relevant scopes, challenge picks smallest group", + name: "returns 403 with smallest group as challenge when token has no relevant scopes", token: "no-scopes", body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_employee_start_date"}}`, wantStatusCode: 403, @@ -425,13 +425,13 @@ func TestMCPAuthMiddleware_PerToolScopes(t *testing.T) { wantScope: `scope="read:all"`, }, { - name: "tools/list is not affected by per-tool scopes", + name: "allows tools/list regardless of per-tool scopes", token: "no-scopes", body: `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`, wantStatusCode: 200, }, { - name: "unknown tool name passes (no per-tool scopes)", + name: "allows tool call when tool name has no per-tool scopes configured", token: "no-scopes", body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"unknown_tool"}}`, wantStatusCode: 200, @@ -469,7 +469,7 @@ func TestMCPAuthMiddleware_PerToolScopes(t *testing.T) { } } -func TestMCPAuthMiddleware_MethodLevelScopes(t *testing.T) { +func TestMCPAuthMiddlewareMethodLevelScopes(t *testing.T) { t.Parallel() const testMetadataURL = "https://test.example/.well-known/oauth-protected-resource/mcp" @@ -513,7 +513,7 @@ func TestMCPAuthMiddleware_MethodLevelScopes(t *testing.T) { wantScope string // expected scope value in WWW-Authenticate, empty if not checked }{ { - name: "tools/list with insufficient scopes - default returns operation scopes only", + name: "returns 403 with only operation scopes when tools/list lacks required scopes", token: "connect-only", body: `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`, scopeChallengeIncludeTokenScopes: false, @@ -521,7 +521,7 @@ func TestMCPAuthMiddleware_MethodLevelScopes(t *testing.T) { wantScope: `scope="mcp:tools:read"`, }, { - name: "tools/list with insufficient scopes - include token scopes", + name: "returns 403 with token and operation scopes when tools/list lacks required scopes and include token scopes is enabled", token: "connect-only", body: `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`, scopeChallengeIncludeTokenScopes: true, @@ -529,14 +529,14 @@ func TestMCPAuthMiddleware_MethodLevelScopes(t *testing.T) { wantScope: `scope="mcp:connect mcp:tools:read"`, }, { - name: "tools/list with sufficient scopes succeeds", + name: "allows tools/list when token has required scopes", token: "connect-and-read", body: `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`, scopeChallengeIncludeTokenScopes: false, wantStatusCode: 200, }, { - name: "tools/call with insufficient scopes - default returns operation scopes only", + name: "returns 403 with only operation scopes when tools/call lacks required scopes", token: "connect-and-read", body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_graphql"}}`, scopeChallengeIncludeTokenScopes: false, @@ -544,7 +544,7 @@ func TestMCPAuthMiddleware_MethodLevelScopes(t *testing.T) { wantScope: `scope="mcp:tools:write"`, }, { - name: "tools/call with insufficient scopes - include token scopes", + name: "returns 403 with token and operation scopes when tools/call lacks required scopes and include token scopes is enabled", token: "connect-and-read", body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_graphql"}}`, scopeChallengeIncludeTokenScopes: true, @@ -552,14 +552,14 @@ func TestMCPAuthMiddleware_MethodLevelScopes(t *testing.T) { wantScope: `scope="mcp:connect mcp:tools:read mcp:tools:write"`, }, { - name: "tools/call with all scopes succeeds", + name: "allows tools/call when token has all required scopes", token: "all-scopes", body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_graphql"}}`, scopeChallengeIncludeTokenScopes: false, wantStatusCode: 200, }, { - name: "unknown method with no scope requirements succeeds", + name: "allows unknown method when no scope requirements are configured", token: "connect-only", body: `{"jsonrpc":"2.0","id":1,"method":"ping"}`, scopeChallengeIncludeTokenScopes: false, @@ -591,7 +591,7 @@ func TestMCPAuthMiddleware_MethodLevelScopes(t *testing.T) { } } -func TestMCPAuthMiddleware_BuiltinToolScopes(t *testing.T) { +func TestMCPAuthMiddlewareBuiltinToolScopes(t *testing.T) { t.Parallel() const testMetadataURL = "https://test.example/.well-known/oauth-protected-resource/mcp" @@ -629,46 +629,46 @@ func TestMCPAuthMiddleware_BuiltinToolScopes(t *testing.T) { wantScope string }{ { - name: "execute_graphql without required scope returns 403", + name: "returns 403 when execute_graphql lacks required builtin scope", token: "base-only", body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_graphql"}}`, wantStatusCode: 403, wantScope: `scope="mcp:graphql:execute"`, }, { - name: "execute_graphql with required scope passes", + name: "allows execute_graphql when token has required builtin scope", token: "has-graphql-execute", body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_graphql"}}`, wantStatusCode: 200, }, { - name: "get_schema without required scope returns 403", + name: "returns 403 when get_schema lacks required builtin scope", token: "base-only", body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_schema"}}`, wantStatusCode: 403, wantScope: `scope="mcp:schema:read"`, }, { - name: "get_schema with required scope passes", + name: "allows get_schema when token has required builtin scope", token: "has-schema-read", body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_schema"}}`, wantStatusCode: 200, }, { - name: "get_operation_info without required scope returns 403", + name: "returns 403 when get_operation_info lacks required builtin scope", token: "base-only", body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_operation_info"}}`, wantStatusCode: 403, wantScope: `scope="mcp:ops:read"`, }, { - name: "get_operation_info with required scope passes", + name: "allows get_operation_info when token has required builtin scope", token: "has-ops-read", body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_operation_info"}}`, wantStatusCode: 200, }, { - name: "non-builtin tool is not affected by builtin scopes", + name: "allows non-builtin tool regardless of builtin scopes", token: "base-only", body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_operation_get_users"}}`, wantStatusCode: 200, diff --git a/router/pkg/mcpserver/scope_challenge_test.go b/router/pkg/mcpserver/scope_challenge_test.go index 689f5d10d6..44df50dbc8 100644 --- a/router/pkg/mcpserver/scope_challenge_test.go +++ b/router/pkg/mcpserver/scope_challenge_test.go @@ -18,25 +18,25 @@ func TestBestScopeChallenge(t *testing.T) { // --- Simple OR scopes (single field, single-scope groups) --- // e.g. Query.topSecretFederationFacts → [["read:fact"], ["read:all"]] { - name: "simple OR: token satisfies first group", + name: "returns nil when token satisfies first OR group", tokenScopes: []string{"read:fact"}, combinedOrScopes: [][]string{{"read:fact"}, {"read:all"}}, want: nil, }, { - name: "simple OR: token satisfies second group", + name: "returns nil when token satisfies second OR group", tokenScopes: []string{"read:all"}, combinedOrScopes: [][]string{{"read:fact"}, {"read:all"}}, want: nil, }, { - name: "simple OR: empty token picks first group on tie", + name: "returns first group as challenge when token is empty and all groups tie", tokenScopes: []string{}, combinedOrScopes: [][]string{{"read:fact"}, {"read:all"}}, want: []string{"read:fact"}, }, { - name: "simple OR: unrelated token picks first group on tie", + name: "returns first group as challenge when token has only unrelated scopes", tokenScopes: []string{"read:other"}, combinedOrScopes: [][]string{{"read:fact"}, {"read:all"}}, want: []string{"read:fact"}, @@ -45,25 +45,25 @@ func TestBestScopeChallenge(t *testing.T) { // --- Mutation with simple OR scopes --- // e.g. Mutation.addFact → [["write:fact"], ["write:all"]] { - name: "mutation OR: token has write:fact passes", + name: "returns nil when token has matching write scope for first OR group", tokenScopes: []string{"write:fact"}, combinedOrScopes: [][]string{{"write:fact"}, {"write:all"}}, want: nil, }, { - name: "mutation OR: token has write:all passes", + name: "returns nil when token has wildcard write scope for second OR group", tokenScopes: []string{"write:all"}, combinedOrScopes: [][]string{{"write:fact"}, {"write:all"}}, want: nil, }, { - name: "mutation OR: wrong category scope picks first group", + name: "returns first group as challenge when token has scope from wrong category", tokenScopes: []string{"read:fact"}, combinedOrScopes: [][]string{{"write:fact"}, {"write:all"}}, want: []string{"write:fact"}, }, { - name: "mutation OR: empty token picks first group", + name: "returns first group as challenge when token is empty for mutation", tokenScopes: []string{}, combinedOrScopes: [][]string{{"write:fact"}, {"write:all"}}, want: []string{"write:fact"}, @@ -72,31 +72,31 @@ func TestBestScopeChallenge(t *testing.T) { // --- AND scopes with OR alternative --- // e.g. Employee.startDate → [["read:employee", "read:private"], ["read:all"]] { - name: "AND group: token satisfies full AND group", + name: "returns nil when token satisfies all scopes in an AND group", tokenScopes: []string{"read:employee", "read:private"}, combinedOrScopes: [][]string{{"read:employee", "read:private"}, {"read:all"}}, want: nil, }, { - name: "AND group: token satisfies shortcut group", + name: "returns nil when token satisfies alternative single-scope OR group", tokenScopes: []string{"read:all"}, combinedOrScopes: [][]string{{"read:employee", "read:private"}, {"read:all"}}, want: nil, }, { - name: "AND group: partial match picks first group on tie (1 missing each)", + name: "returns first group when token partially matches on tie", tokenScopes: []string{"read:employee"}, combinedOrScopes: [][]string{{"read:employee", "read:private"}, {"read:all"}}, want: []string{"read:employee", "read:private"}, }, { - name: "AND group: other partial match also picks first on tie", + name: "returns first group when token has the other partial match on tie", tokenScopes: []string{"read:private"}, combinedOrScopes: [][]string{{"read:employee", "read:private"}, {"read:all"}}, want: []string{"read:employee", "read:private"}, }, { - name: "AND group: empty token picks shorter group (read:all needs 1 vs 2)", + name: "returns shorter group as challenge when token is empty and groups differ in size", tokenScopes: []string{}, combinedOrScopes: [][]string{{"read:employee", "read:private"}, {"read:all"}}, want: []string{"read:all"}, @@ -105,7 +105,7 @@ func TestBestScopeChallenge(t *testing.T) { // --- Cross-product: multiple scoped fields --- // 3 scoped fields with cross-product yielding 6 groups (see plan Operation 5) { - name: "cross-product: token has read:all picks simplest group", + name: "returns group with fewest missing scopes when token has wildcard", tokenScopes: []string{"read:all"}, combinedOrScopes: [][]string{ {"read:fact", "read:scalar", "read:miscellaneous"}, @@ -118,7 +118,7 @@ func TestBestScopeChallenge(t *testing.T) { want: []string{"read:all", "read:miscellaneous"}, // missing only 1 }, { - name: "cross-product: partial match narrows to best group", + name: "returns group with fewest missing scopes for partial match", tokenScopes: []string{"read:fact", "read:scalar"}, combinedOrScopes: [][]string{ {"read:fact", "read:scalar", "read:miscellaneous"}, @@ -131,7 +131,7 @@ func TestBestScopeChallenge(t *testing.T) { want: []string{"read:fact", "read:scalar", "read:miscellaneous"}, // missing only "read:miscellaneous" }, { - name: "cross-product: empty token picks group with fewest total scopes", + name: "returns group with fewest total scopes when token is empty", tokenScopes: []string{}, combinedOrScopes: [][]string{ {"read:fact", "read:scalar", "read:miscellaneous"}, @@ -147,7 +147,7 @@ func TestBestScopeChallenge(t *testing.T) { // --- Cross-subgraph aggregation --- // Products + Employees subgraph scoped fields, cross-product yields 4 groups { - name: "cross-subgraph: token has read:all passes single-scope group", + name: "returns nil when token has wildcard scope satisfying aggregated groups", tokenScopes: []string{"read:all"}, combinedOrScopes: [][]string{ {"read:fact", "read:employee", "read:private"}, @@ -158,7 +158,7 @@ func TestBestScopeChallenge(t *testing.T) { want: nil, }, { - name: "cross-subgraph: partial match picks closest group", + name: "returns closest group when token partially matches across subgraphs", tokenScopes: []string{"read:fact"}, combinedOrScopes: [][]string{ {"read:fact", "read:employee", "read:private"}, @@ -169,7 +169,7 @@ func TestBestScopeChallenge(t *testing.T) { want: []string{"read:fact", "read:all"}, // missing 1, tied with group 4, first tie wins }, { - name: "cross-subgraph: unrelated partial match picks smallest group", + name: "returns smallest group when token has unrelated partial match", tokenScopes: []string{"read:employee"}, combinedOrScopes: [][]string{ {"read:fact", "read:employee", "read:private"}, @@ -180,7 +180,7 @@ func TestBestScopeChallenge(t *testing.T) { want: []string{"read:all"}, // missing 1, clear winner }, { - name: "cross-subgraph: empty token picks smallest group", + name: "returns smallest group when token is empty across subgraphs", tokenScopes: []string{}, combinedOrScopes: [][]string{ {"read:fact", "read:employee", "read:private"}, @@ -193,25 +193,25 @@ func TestBestScopeChallenge(t *testing.T) { // --- Edge cases --- { - name: "nil combined scopes returns nil", + name: "returns nil when combined scopes is nil", tokenScopes: []string{"some:scope"}, combinedOrScopes: nil, want: nil, }, { - name: "empty combined scopes returns nil", + name: "returns nil when combined scopes is empty", tokenScopes: []string{"some:scope"}, combinedOrScopes: [][]string{}, want: nil, }, { - name: "single AND-group not satisfied returns that group", + name: "returns the only group when single AND group is not satisfied", tokenScopes: []string{"a"}, combinedOrScopes: [][]string{{"a", "b", "c"}}, want: []string{"a", "b", "c"}, }, { - name: "single AND-group fully satisfied returns nil", + name: "returns nil when single AND group is fully satisfied", tokenScopes: []string{"a", "b", "c"}, combinedOrScopes: [][]string{{"a", "b", "c"}}, want: nil, @@ -238,28 +238,28 @@ func TestBestScopeChallengeWithExisting(t *testing.T) { want []string }{ { - name: "include existing: unions token scopes with best group", + name: "returns union of token scopes and best group when include existing is true", tokenScopes: []string{"init", "mcp:tools:write", "a"}, combinedOrScopes: [][]string{{"a", "b", "d"}, {"a", "c", "d"}}, includeExisting: true, want: []string{"init", "mcp:tools:write", "a", "b", "d"}, }, { - name: "exclude existing: returns only best group", + name: "returns only the best group when include existing is false", tokenScopes: []string{"init", "mcp:tools:write", "a"}, combinedOrScopes: [][]string{{"a", "b", "d"}, {"a", "c", "d"}}, includeExisting: false, want: []string{"a", "b", "d"}, }, { - name: "include existing: passes returns nil", + name: "returns nil when token satisfies scopes even with include existing enabled", tokenScopes: []string{"a", "b", "d"}, combinedOrScopes: [][]string{{"a", "b", "d"}, {"a", "c", "d"}}, includeExisting: true, want: nil, }, { - name: "include existing: deduplicates overlapping scopes", + name: "deduplicates overlapping scopes when merging token scopes with best group", tokenScopes: []string{"read:employee"}, combinedOrScopes: [][]string{{"read:employee", "read:private"}, {"read:all"}}, includeExisting: true, @@ -286,43 +286,43 @@ func TestSatisfiesAnyGroup(t *testing.T) { want bool }{ { - name: "satisfies first AND-group", + name: "returns true when token satisfies first AND group", tokenScopes: []string{"a", "b"}, orScopes: [][]string{{"a", "b"}, {"c", "d"}}, want: true, }, { - name: "satisfies second AND-group with extra scopes", + name: "returns true when token satisfies second AND group with extra scopes", tokenScopes: []string{"c", "d", "e"}, orScopes: [][]string{{"a", "b"}, {"c", "d"}}, want: true, }, { - name: "partial match on each group fails", + name: "returns false when token only partially matches each AND group", tokenScopes: []string{"a", "c"}, orScopes: [][]string{{"a", "b"}, {"c", "d"}}, want: false, }, { - name: "empty requirements always passes", + name: "returns true when required scopes are empty", tokenScopes: []string{}, orScopes: [][]string{}, want: true, }, { - name: "nil requirements always passes", + name: "returns true when required scopes are nil", tokenScopes: []string{}, orScopes: nil, want: true, }, { - name: "empty token with requirements fails", + name: "returns false when token is empty but scopes are required", tokenScopes: []string{}, orScopes: [][]string{{"a"}}, want: false, }, { - name: "token superset of AND-group passes", + name: "returns true when token is a superset of an AND group", tokenScopes: []string{"a", "b", "c", "d"}, orScopes: [][]string{{"a", "b"}}, want: true, diff --git a/router/pkg/mcpserver/scope_extractor_test.go b/router/pkg/mcpserver/scope_extractor_test.go index a3000db84d..62869cfbea 100644 --- a/router/pkg/mcpserver/scope_extractor_test.go +++ b/router/pkg/mcpserver/scope_extractor_test.go @@ -182,7 +182,7 @@ func TestExtractScopesForOperation(t *testing.T) { wantNoScopes bool // expect nil/empty RequiredScopes }{ { - name: "no scoped fields: list employees with public info", + name: "returns no scoped fields for query with only public fields", operation: ` query ListEmployees { employees { @@ -198,7 +198,7 @@ func TestExtractScopesForOperation(t *testing.T) { wantNoScopes: true, }, { - name: "single scoped root query field: facts titles only", + name: "returns one scoped field for scoped root query field", operation: ` query GetTopSecretFacts { topSecretFederationFacts { @@ -210,7 +210,7 @@ func TestExtractScopesForOperation(t *testing.T) { wantFields: 1, // Query.topSecretFederationFacts }, { - name: "single scoped mutation field", + name: "returns one scoped field for scoped mutation", operation: ` mutation AddFact($fact: TopSecretFactInput!) { addFact(fact: $fact) { @@ -222,7 +222,7 @@ func TestExtractScopesForOperation(t *testing.T) { wantFields: 1, // Mutation.addFact }, { - name: "single scoped entity field with AND group", + name: "returns one scoped field for entity field with AND group", operation: ` query GetEmployeeStartDate($id: Int!) { employee(id: $id) { @@ -234,7 +234,7 @@ func TestExtractScopesForOperation(t *testing.T) { wantFields: 1, // Employee.startDate }, { - name: "multiple scoped fields via inline fragments on different types", + name: "returns multiple scoped fields for inline fragments on different types", operation: ` query GetTopSecretFactsWithDescriptions { topSecretFederationFacts { @@ -251,7 +251,7 @@ func TestExtractScopesForOperation(t *testing.T) { wantFields: 3, // Query.topSecretFederationFacts, DirectiveFact.description, MiscellaneousFact.description }, { - name: "cross-subgraph scoped fields from products and employees", + name: "returns scoped fields aggregated from multiple subgraphs", operation: ` query GetFactsAndEmployeeStartDate($id: Int!) { topSecretFederationFacts { @@ -265,7 +265,7 @@ func TestExtractScopesForOperation(t *testing.T) { wantFields: 2, // Query.topSecretFederationFacts, Employee.startDate }, { - name: "no scoped fields despite touching scoped type (startDate excluded)", + name: "returns no scoped fields when only unscoped fields are selected on a scoped type", operation: ` query GetEmployeeBasicInfo($id: Int!) { employee(id: $id) { @@ -299,19 +299,12 @@ func TestExtractScopesForOperation(t *testing.T) { } }) } -} - -func TestExtractScopesForOperation_FieldDetails(t *testing.T) { - t.Parallel() - - fieldConfigs := testFieldConfigs() - schemaDoc := parseTestSchema(t) - - extractor := NewScopeExtractor(fieldConfigs, &schemaDoc, 2048) - - t.Run("root query field returns correct OR-of-AND scopes", func(t *testing.T) { + t.Run("returns correct OR-of-AND scopes for root query field", func(t *testing.T) { t.Parallel() + schemaDoc := parseTestSchema(t) + extractor := NewScopeExtractor(fieldConfigs, &schemaDoc, 2048) + opDoc, report := astparser.ParseGraphqlDocumentString(` query GetTopSecretFacts { topSecretFederationFacts { @@ -328,8 +321,11 @@ func TestExtractScopesForOperation_FieldDetails(t *testing.T) { assert.Equal(t, [][]string{{"read:fact"}, {"read:all"}}, fieldReqs[0].OrScopes) }) - t.Run("entity field returns AND scopes with OR alternative", func(t *testing.T) { + t.Run("returns AND scopes with OR alternative for entity field", func(t *testing.T) { t.Parallel() + schemaDoc := parseTestSchema(t) + extractor := NewScopeExtractor(fieldConfigs, &schemaDoc, 2048) + opDoc, report := astparser.ParseGraphqlDocumentString(` query GetEmployeeStartDate($id: Int!) { employee(id: $id) { @@ -346,8 +342,11 @@ func TestExtractScopesForOperation_FieldDetails(t *testing.T) { assert.Equal(t, [][]string{{"read:employee", "read:private"}, {"read:all"}}, fieldReqs[0].OrScopes) }) - t.Run("mutation field returns correct scopes", func(t *testing.T) { + t.Run("returns correct scopes for mutation field", func(t *testing.T) { t.Parallel() + schemaDoc := parseTestSchema(t) + extractor := NewScopeExtractor(fieldConfigs, &schemaDoc, 2048) + opDoc, report := astparser.ParseGraphqlDocumentString(` mutation AddFact($fact: TopSecretFactInput!) { addFact(fact: $fact) { @@ -374,12 +373,12 @@ func TestComputeCombinedScopes(t *testing.T) { want [][]string }{ { - name: "no field requirements returns nil", + name: "returns nil when there are no field requirements", fieldReqs: nil, want: nil, }, { - name: "single field passes through directly", + name: "passes through a single field's scopes directly", fieldReqs: []FieldScopeRequirement{ { TypeName: "Query", @@ -390,7 +389,7 @@ func TestComputeCombinedScopes(t *testing.T) { want: [][]string{{"read:fact"}, {"read:all"}}, }, { - name: "two fields: cross-product with dedup", + name: "computes cross-product with dedup for two fields", fieldReqs: []FieldScopeRequirement{ { TypeName: "Query", @@ -411,7 +410,7 @@ func TestComputeCombinedScopes(t *testing.T) { }, }, { - name: "three fields: full cross-product with dedup", + name: "computes full cross-product with dedup for three fields", fieldReqs: []FieldScopeRequirement{ { TypeName: "Query", @@ -462,42 +461,41 @@ func TestComputeCombinedScopes(t *testing.T) { assert.Equal(t, tt.want, got) }) } -} - -func TestComputeCombinedScopes_ExceedsLimit(t *testing.T) { - t.Parallel() - schemaDoc := parseTestSchema(t) - extractor := NewScopeExtractor(testFieldConfigs(), &schemaDoc, 2048) - - // Build field requirements that will exceed MaxScopeCombinations (2048). - // 12 fields × 2 OR-groups each = 2^12 = 4096 combinations > 2048. - fieldReqs := make([]FieldScopeRequirement, 12) - for i := range fieldReqs { - fieldReqs[i] = FieldScopeRequirement{ - TypeName: "Query", - FieldName: fmt.Sprintf("field_%d", i), - OrScopes: [][]string{{"scope_a"}, {"scope_b"}}, + t.Run("returns error when combinations exceed configured limit", func(t *testing.T) { + t.Parallel() + schemaDoc := parseTestSchema(t) + extractor := NewScopeExtractor(testFieldConfigs(), &schemaDoc, 2048) + + // Build field requirements that will exceed MaxScopeCombinations (2048). + // 12 fields × 2 OR-groups each = 2^12 = 4096 combinations > 2048. + fieldReqs := make([]FieldScopeRequirement, 12) + for i := range fieldReqs { + fieldReqs[i] = FieldScopeRequirement{ + TypeName: "Query", + FieldName: fmt.Sprintf("field_%d", i), + OrScopes: [][]string{{"scope_a"}, {"scope_b"}}, + } } - } - got, err := extractor.ComputeCombinedScopes(fieldReqs) - assert.Error(t, err) - assert.Nil(t, got) - assert.Contains(t, err.Error(), "scope combination limit") + got, err := extractor.ComputeCombinedScopes(fieldReqs) + assert.Error(t, err) + assert.Nil(t, got) + assert.Contains(t, err.Error(), "scope combination limit") + }) } -func TestCrossProduct_EdgeCases(t *testing.T) { +func TestCrossProduct(t *testing.T) { t.Parallel() - t.Run("empty OR list means no way to pass, result is empty", func(t *testing.T) { + t.Run("returns empty result when OR list is empty", func(t *testing.T) { t.Parallel() got, err := crossProduct([][]string{}, [][]string{{"x"}}, 100) require.NoError(t, err) assert.Empty(t, got) }) - t.Run("empty AND group means no scopes required, merges to just the other side", func(t *testing.T) { + t.Run("returns the other side unchanged when AND group is empty", func(t *testing.T) { t.Parallel() got, err := crossProduct([][]string{{}}, [][]string{{"x"}}, 100) require.NoError(t, err)