diff --git a/api/v1alpha1/mcp_route.go b/api/v1alpha1/mcp_route.go
index 5c037f89e4..fa4d26dabe 100644
--- a/api/v1alpha1/mcp_route.go
+++ b/api/v1alpha1/mcp_route.go
@@ -177,6 +177,8 @@ type MCPBackendAPIKey struct {
}
// MCPRouteSecurityPolicy defines the security policy for a MCPRoute.
+//
+// +kubebuilder:validation:XValidation:rule="!has(self.authorization) || has(self.oauth)",message="oauth must be configured when authorization is set"
type MCPRouteSecurityPolicy struct {
// OAuth defines the configuration for the MCP spec compatible OAuth authentication.
//
@@ -192,6 +194,11 @@ type MCPRouteSecurityPolicy struct {
//
// +optional
ExtAuth *egv1a1.ExtAuth `json:"extAuth,omitempty"`
+
+ // Authorization defines the configuration for the MCP spec compatible authorization.
+ //
+ // +optional
+ Authorization *MCPRouteAuthorization `json:"authorization,omitempty"`
}
// MCPRouteOAuth defines a MCP spec compatible OAuth authentication configuration for a MCPRoute.
@@ -227,6 +234,87 @@ type MCPRouteOAuth struct {
ProtectedResourceMetadata ProtectedResourceMetadata `json:"protectedResourceMetadata"`
}
+// MCPRouteAuthorization defines the authorization configuration for a MCPRoute.
+type MCPRouteAuthorization struct {
+ // Rules defines a list of authorization rules.
+ //
+ // Requests that match any rule and satisfy the rule's conditions will be allowed.
+ // Requests that do not match any rule or fail to satisfy the matched rule's conditions will be denied.
+ // If no rules are defined, all requests will be denied.
+ //
+ // +optional
+ Rules []MCPRouteAuthorizationRule `json:"rules,omitempty"`
+}
+
+// MCPRouteAuthorizationRule defines an authorization rule for MCPRoute based on the MCP authorization spec.
+// Reference: https://modelcontextprotocol.io/specification/draft/basic/authorization#scope-challenge-handling
+type MCPRouteAuthorizationRule struct {
+ // Source defines the authorization source for this rule.
+ //
+ // +kubebuilder:validation:Required
+ Source MCPAuthorizationSource `json:"source"`
+
+ // Target defines the authorization target for this rule.
+ //
+ // +kubebuilder:validation:Required
+ Target MCPAuthorizationTarget `json:"target"`
+}
+
+// MCPAuthorizationTarget defines the target of an authorization rule.
+type MCPAuthorizationTarget struct {
+ // Tools defines the list of tools this rule applies to.
+ //
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinItems=1
+ // +kubebuilder:validation:MaxItems=16
+ Tools []ToolCall `json:"tools"`
+}
+
+// MCPAuthorizationSource defines the source of an authorization rule.
+type MCPAuthorizationSource struct {
+ // JWTSource defines the JWT scopes required for this rule to match.
+ //
+ // +kubebuilder:validation:Required
+ JWTSource JWTSource `json:"jwtSource"`
+
+ // TODO: JWTSource can be optional in the future when we support more source types.
+}
+
+// JWTSource defines the MCP authorization source for JWT tokens.
+type JWTSource struct {
+ // Scopes defines the list of JWT scopes required for the rule.
+ // If multiple scopes are specified, all scopes must be present in the JWT for the rule to match.
+ //
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinItems=1
+ // +kubebuilder:validation:MaxItems=16
+ Scopes []egv1a1.JWTScope `json:"scopes"`
+
+ // TODO : we can add more fields in the future, e.g., audiences, claims, etc.
+}
+
+// ToolCall represents a tool call in the MCP authorization target.
+type ToolCall struct {
+ // BackendName is the name of the backend this tool belongs to.
+ //
+ // +kubebuilder:validation:Required
+ BackendName string `json:"backendName"`
+
+ // ToolName is the name of the tool.
+ //
+ // +kubebuilder:validation:Required
+ ToolName string `json:"toolName"`
+
+ // Arguments is a CEL expression that must evaluate to true for the rule to match.
+ // The expression is evaluated with a single variable "args" bound to the tool call arguments as a dynamic object.
+ // Guard against missing fields with null checks (e.g., args["foo"] != null && args["foo"]["bar"] == "val").
+ //
+ // +kubebuilder:validation:Optional
+ // +kubebuilder:validation:MaxLength=4096
+ // +optional
+ Arguments *string `json:"arguments,omitempty"`
+}
+
// JWKS defines how to obtain JSON Web Key Sets (JWKS) either from a remote HTTP/HTTPS endpoint or from a local source.
// +kubebuilder:validation:XValidation:rule="has(self.remoteJWKS) || has(self.localJWKS)", message="either remoteJWKS or localJWKS must be specified."
// +kubebuilder:validation:XValidation:rule="!(has(self.remoteJWKS) && has(self.localJWKS))", message="remoteJWKS and localJWKS cannot both be specified."
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index d2d42ae93a..e98c8872b7 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -915,6 +915,26 @@ func (in *JWKS) DeepCopy() *JWKS {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *JWTSource) DeepCopyInto(out *JWTSource) {
+ *out = *in
+ if in.Scopes != nil {
+ in, out := &in.Scopes, &out.Scopes
+ *out = make([]apiv1alpha1.JWTScope, len(*in))
+ copy(*out, *in)
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JWTSource.
+func (in *JWTSource) DeepCopy() *JWTSource {
+ if in == nil {
+ return nil
+ }
+ out := new(JWTSource)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *LLMRequestCost) DeepCopyInto(out *LLMRequestCost) {
*out = *in
@@ -935,6 +955,44 @@ func (in *LLMRequestCost) DeepCopy() *LLMRequestCost {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *MCPAuthorizationSource) DeepCopyInto(out *MCPAuthorizationSource) {
+ *out = *in
+ in.JWTSource.DeepCopyInto(&out.JWTSource)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPAuthorizationSource.
+func (in *MCPAuthorizationSource) DeepCopy() *MCPAuthorizationSource {
+ if in == nil {
+ return nil
+ }
+ out := new(MCPAuthorizationSource)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *MCPAuthorizationTarget) DeepCopyInto(out *MCPAuthorizationTarget) {
+ *out = *in
+ if in.Tools != nil {
+ in, out := &in.Tools, &out.Tools
+ *out = make([]ToolCall, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPAuthorizationTarget.
+func (in *MCPAuthorizationTarget) DeepCopy() *MCPAuthorizationTarget {
+ if in == nil {
+ return nil
+ }
+ out := new(MCPAuthorizationTarget)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *MCPBackendAPIKey) DeepCopyInto(out *MCPBackendAPIKey) {
*out = *in
@@ -1012,6 +1070,45 @@ func (in *MCPRoute) DeepCopyObject() runtime.Object {
return nil
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *MCPRouteAuthorization) DeepCopyInto(out *MCPRouteAuthorization) {
+ *out = *in
+ if in.Rules != nil {
+ in, out := &in.Rules, &out.Rules
+ *out = make([]MCPRouteAuthorizationRule, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRouteAuthorization.
+func (in *MCPRouteAuthorization) DeepCopy() *MCPRouteAuthorization {
+ if in == nil {
+ return nil
+ }
+ out := new(MCPRouteAuthorization)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *MCPRouteAuthorizationRule) DeepCopyInto(out *MCPRouteAuthorizationRule) {
+ *out = *in
+ in.Source.DeepCopyInto(&out.Source)
+ in.Target.DeepCopyInto(&out.Target)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRouteAuthorizationRule.
+func (in *MCPRouteAuthorizationRule) DeepCopy() *MCPRouteAuthorizationRule {
+ if in == nil {
+ return nil
+ }
+ out := new(MCPRouteAuthorizationRule)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *MCPRouteBackendRef) DeepCopyInto(out *MCPRouteBackendRef) {
*out = *in
@@ -1119,6 +1216,11 @@ func (in *MCPRouteSecurityPolicy) DeepCopyInto(out *MCPRouteSecurityPolicy) {
*out = new(apiv1alpha1.ExtAuth)
(*in).DeepCopyInto(*out)
}
+ if in.Authorization != nil {
+ in, out := &in.Authorization, &out.Authorization
+ *out = new(MCPRouteAuthorization)
+ (*in).DeepCopyInto(*out)
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRouteSecurityPolicy.
@@ -1264,6 +1366,26 @@ func (in *ProtectedResourceMetadata) DeepCopy() *ProtectedResourceMetadata {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ToolCall) DeepCopyInto(out *ToolCall) {
+ *out = *in
+ if in.Arguments != nil {
+ in, out := &in.Arguments, &out.Arguments
+ *out = new(string)
+ **out = **in
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToolCall.
+func (in *ToolCall) DeepCopy() *ToolCall {
+ if in == nil {
+ return nil
+ }
+ out := new(ToolCall)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *VersionedAPISchema) DeepCopyInto(out *VersionedAPISchema) {
*out = *in
diff --git a/go.mod b/go.mod
index 8f0f9b7e8e..efa4bb04cc 100644
--- a/go.mod
+++ b/go.mod
@@ -23,7 +23,7 @@ require (
github.com/envoyproxy/go-control-plane v0.14.0
github.com/envoyproxy/go-control-plane/envoy v1.36.0
github.com/go-logr/logr v1.4.3
- github.com/golang-jwt/jwt/v4 v4.5.2
+ github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/cel-go v0.26.1
github.com/google/go-cmp v0.7.0
github.com/google/jsonschema-go v0.3.0
@@ -157,7 +157,6 @@ require (
github.com/go-openapi/validate v0.25.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
- github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
diff --git a/go.sum b/go.sum
index d650f618c1..1262163e38 100644
--- a/go.sum
+++ b/go.sum
@@ -233,8 +233,6 @@ github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlnd
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
-github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
-github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
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/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
diff --git a/internal/controller/gateway.go b/internal/controller/gateway.go
index b1aa5bcb58..8873762184 100644
--- a/internal/controller/gateway.go
+++ b/internal/controller/gateway.go
@@ -471,6 +471,43 @@ func mcpConfig(mcpRoutes []aigv1a1.MCPRoute) *filterapi.MCPConfig {
mcpRoute.Backends = append(
mcpRoute.Backends, mcpBackend)
}
+ // Add authorization configuration for the route.
+ if route.Spec.SecurityPolicy != nil && route.Spec.SecurityPolicy.Authorization != nil {
+ authorization := route.Spec.SecurityPolicy.Authorization
+ mcpRoute.Authorization = &filterapi.MCPRouteAuthorization{}
+
+ if route.Spec.SecurityPolicy.OAuth != nil {
+ mcpRoute.Authorization.ResourceMetadataURL = buildResourceMetadataURL(&route.Spec.SecurityPolicy.OAuth.ProtectedResourceMetadata)
+ }
+
+ for _, rule := range authorization.Rules {
+ scopes := make([]string, len(rule.Source.JWTSource.Scopes))
+ for i, scope := range rule.Source.JWTSource.Scopes {
+ scopes[i] = string(scope)
+ }
+
+ tools := make([]filterapi.ToolCall, len(rule.Target.Tools))
+ for i, tool := range rule.Target.Tools {
+ tools[i] = filterapi.ToolCall{
+ BackendName: tool.BackendName,
+ ToolName: tool.ToolName,
+ Arguments: tool.Arguments,
+ }
+ }
+
+ mcpRule := filterapi.MCPRouteAuthorizationRule{
+ Source: filterapi.MCPAuthorizationSource{
+ JWTSource: filterapi.JWTSource{
+ Scopes: scopes,
+ },
+ },
+ Target: filterapi.MCPAuthorizationTarget{
+ Tools: tools,
+ },
+ }
+ mcpRoute.Authorization.Rules = append(mcpRoute.Authorization.Rules, mcpRule)
+ }
+ }
mc.Routes = append(mc.Routes, mcpRoute)
}
return mc
diff --git a/internal/controller/mcp_route_security_policy.go b/internal/controller/mcp_route_security_policy.go
index 1d2685c93a..82f7d2be25 100644
--- a/internal/controller/mcp_route_security_policy.go
+++ b/internal/controller/mcp_route_security_policy.go
@@ -281,13 +281,11 @@ func (c *MCPRouteController) ensureOAuthProtectedResourceMetadataBTP(ctx context
return nil
}
-// buildWWWAuthenticateHeaderValue constructs the WWW-Authenticate header value according to RFC 9728.
+// buildResourceMetadataURL constructs the OAuth protected resource metadata URL using the resource identifier.
// References:
// * https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-server-location
// * https://datatracker.ietf.org/doc/html/rfc9728#name-www-authenticate-response
-func buildWWWAuthenticateHeaderValue(metadata *aigv1a1.ProtectedResourceMetadata) string {
- // Build resource metadata URL using RFC 8414 compliant pattern.
- // Extract base URL and path from resource identifier.
+func buildResourceMetadataURL(metadata *aigv1a1.ProtectedResourceMetadata) string {
resourceURL := strings.TrimSuffix(metadata.Resource, "/")
var (
@@ -316,7 +314,15 @@ func buildWWWAuthenticateHeaderValue(metadata *aigv1a1.ProtectedResourceMetadata
// they should honor hte value returned here.
// We can't expose these resource at the root, because there may be multiple MCP routes with different OAuth settings, so we need
// to rely on clients properly implementing the spec and using this value returned in the header.
- resourceMetadataURL := fmt.Sprintf("%s%s%s", baseURL, oauthWellKnownProtectedResourceMetadataPath, pathComponent)
+ return fmt.Sprintf("%s%s%s", baseURL, oauthWellKnownProtectedResourceMetadataPath, pathComponent)
+}
+
+// buildWWWAuthenticateHeaderValue constructs the WWW-Authenticate header value according to RFC 9728.
+// References:
+// * https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-server-location
+// * https://datatracker.ietf.org/doc/html/rfc9728#name-www-authenticate-response
+func buildWWWAuthenticateHeaderValue(metadata *aigv1a1.ProtectedResourceMetadata) string {
+ resourceMetadataURL := buildResourceMetadataURL(metadata)
// Build the basic Bearer challenge.
headerValue := `Bearer error="invalid_request", error_description="No access token was provided in this request"`
diff --git a/internal/filterapi/mcpconfig.go b/internal/filterapi/mcpconfig.go
index 852831f592..49a4c91321 100644
--- a/internal/filterapi/mcpconfig.go
+++ b/internal/filterapi/mcpconfig.go
@@ -27,6 +27,9 @@ type MCPRoute struct {
// Backends is the list of backends that this route can route to.
Backends []MCPBackend `json:"backends"`
+
+ // Authorization is the authorization configuration for this route.
+ Authorization *MCPRouteAuthorization `json:"authorization,omitempty"`
}
// MCPBackend is the MCP backend configuration.
@@ -58,3 +61,54 @@ type MCPToolSelector struct {
// MCPRouteName is the name of the MCP route.
type MCPRouteName = string
+
+// MCPRouteAuthorization defines the authorization configuration for a MCPRoute.
+type MCPRouteAuthorization struct {
+ // Rules defines a list of authorization rules.
+ // Requests that match any rule and satisfy the rule's conditions will be allowed.
+ // Requests that do not match any rule or fail to satisfy the matched rule's conditions will be denied.
+ // If no rules are defined, all requests will be denied.
+ Rules []MCPRouteAuthorizationRule `json:"rules,omitempty"`
+
+ // ResourceMetadataURL is the URI of the OAuth Protected Resource Metadata document for this route.
+ // This is used to populate the WWW-Authenticate header when scope-based authorization fails.
+ ResourceMetadataURL string `json:"resourceMetadataURL,omitempty"`
+}
+
+// MCPRouteAuthorizationRule defines an authorization rule for MCPRoute based on the MCP authorization spec.
+// Reference: https://modelcontextprotocol.io/specification/draft/basic/authorization#scope-challenge-handling
+type MCPRouteAuthorizationRule struct {
+ // Source defines the authorization source for this rule.
+ Source MCPAuthorizationSource `json:"source"`
+
+ // Target defines the authorization target for this rule.
+ Target MCPAuthorizationTarget `json:"target"`
+}
+
+type MCPAuthorizationTarget struct {
+ // Tools defines the list of tools this rule applies to.
+ Tools []ToolCall `json:"tools"`
+}
+
+type MCPAuthorizationSource struct {
+ // JWTSource defines the JWT scopes required for this rule to match.
+ JWTSource JWTSource `json:"jwtSource,omitempty"`
+}
+
+type JWTSource struct {
+ // Scopes defines the list of JWT scopes required for the rule.
+ // If multiple scopes are specified, all scopes must be present in the JWT for the rule to match.
+ Scopes []string `json:"scopes"`
+}
+
+type ToolCall struct {
+ // BackendName is the name of the backend this tool belongs to.
+ BackendName string `json:"backendName"`
+
+ // ToolName is the name of the tool.
+ ToolName string `json:"toolName"`
+
+ // Arguments is a CEL expression evaluated against the tool call arguments map.
+ // The expression must evaluate to true for the rule to apply.
+ Arguments *string `json:"arguments,omitempty"`
+}
diff --git a/internal/mcpproxy/authorization.go b/internal/mcpproxy/authorization.go
new file mode 100644
index 0000000000..d1d7e6d769
--- /dev/null
+++ b/internal/mcpproxy/authorization.go
@@ -0,0 +1,239 @@
+// Copyright Envoy AI Gateway Authors
+// SPDX-License-Identifier: Apache-2.0
+// The full text of the Apache license is available in the LICENSE file at
+// the root of the repo.
+
+package mcpproxy
+
+import (
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "strings"
+
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/google/cel-go/cel"
+ "github.com/google/cel-go/common/types"
+ "k8s.io/apimachinery/pkg/util/sets"
+
+ "github.com/envoyproxy/ai-gateway/internal/filterapi"
+)
+
+type compiledAuthorization struct {
+ ResourceMetadataURL string
+ Rules []compiledAuthorizationRule
+}
+
+type compiledAuthorizationRule struct {
+ Source filterapi.MCPAuthorizationSource
+ Target []compiledToolCall
+}
+
+type compiledToolCall struct {
+ BackendName string
+ ToolName string
+ Expression string
+ program cel.Program
+}
+
+// compileAuthorization compiles the MCPRouteAuthorization into a compiledAuthorization for efficient CEL evaluation.
+func compileAuthorization(auth *filterapi.MCPRouteAuthorization) (*compiledAuthorization, error) {
+ if auth == nil {
+ return nil, nil
+ }
+
+ env, err := cel.NewEnv(
+ cel.Variable("args", cel.DynType),
+ cel.OptionalTypes(),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to create CEL environment: %w", err)
+ }
+
+ compiled := &compiledAuthorization{
+ ResourceMetadataURL: auth.ResourceMetadataURL,
+ }
+
+ for _, rule := range auth.Rules {
+ cr := compiledAuthorizationRule{
+ Source: rule.Source,
+ }
+ for _, tool := range rule.Target.Tools {
+ ct := compiledToolCall{
+ BackendName: tool.BackendName,
+ ToolName: tool.ToolName,
+ }
+ if tool.Arguments != nil && strings.TrimSpace(*tool.Arguments) != "" {
+ expr := strings.TrimSpace(*tool.Arguments)
+ ast, issues := env.Compile(expr)
+ if issues != nil && issues.Err() != nil {
+ return nil, fmt.Errorf("failed to compile arguments CEL for tool %s/%s: %w", tool.BackendName, tool.ToolName, issues.Err())
+ }
+ program, err := env.Program(ast, cel.CostLimit(10000), cel.EvalOptions(cel.OptOptimize))
+ if err != nil {
+ return nil, fmt.Errorf("failed to build arguments CEL program for tool %s/%s: %w", tool.BackendName, tool.ToolName, err)
+ }
+ ct.Expression = expr
+ ct.program = program
+ }
+ cr.Target = append(cr.Target, ct)
+ }
+ compiled.Rules = append(compiled.Rules, cr)
+ }
+
+ return compiled, nil
+}
+
+// authorizeRequest authorizes the request based on the given MCPRouteAuthorization configuration.
+
+func (m *MCPProxy) authorizeRequest(authorization *compiledAuthorization, headers http.Header, backendName, toolName string, arguments any) (bool, []string) {
+ if authorization == nil {
+ return true, nil
+ }
+
+ // If no rules are defined, deny all requests.
+ if len(authorization.Rules) == 0 {
+ return false, nil
+ }
+
+ // If the rules are defined, a valid bearer token is required.
+ token, err := bearerToken(headers.Get("Authorization"))
+ // This is just a sanity check. The actual JWT verification is performed by Envoy before reaching here, and the token
+ // should always be present and valid.
+ if err != nil {
+ m.l.Info("missing or invalid bearer token", slog.String("error", err.Error()))
+ return false, nil
+ }
+
+ claims := jwt.MapClaims{}
+ // JWT verification is performed by Envoy before reaching here. So we only need to parse the token without verification.
+ if _, _, err := jwt.NewParser().ParseUnverified(token, claims); err != nil {
+ m.l.Info("failed to parse JWT token", slog.String("error", err.Error()))
+ return false, nil
+ }
+
+ scopeSet := sets.New(extractScopes(claims)...)
+ var requiredScopesForChallenge []string
+
+ for _, rule := range authorization.Rules {
+ if !m.toolMatches(backendName, toolName, rule.Target, arguments) {
+ continue
+ }
+
+ requiredScopes := rule.Source.JWTSource.Scopes
+ if scopesSatisfied(scopeSet, requiredScopes) {
+ return true, nil
+ }
+
+ // Keep track of the smallest set of required scopes for challenge.
+ if len(requiredScopesForChallenge) == 0 || len(requiredScopes) < len(requiredScopesForChallenge) {
+ requiredScopesForChallenge = requiredScopes
+ }
+ }
+
+ return false, requiredScopesForChallenge
+}
+
+func bearerToken(header string) (string, error) {
+ if header == "" {
+ return "", errors.New("missing Authorization header")
+ }
+
+ parts := strings.SplitN(header, " ", 2)
+ if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
+ return "", errors.New("invalid Authorization header")
+ }
+
+ token := strings.TrimSpace(parts[1])
+ if token == "" {
+ return "", errors.New("missing bearer token")
+ }
+ return token, nil
+}
+
+func extractScopes(claims jwt.MapClaims) []string {
+ raw, ok := claims["scope"]
+ if !ok {
+ return nil
+ }
+
+ switch v := raw.(type) {
+ case string:
+ return strings.Fields(v)
+ case []string:
+ return v
+ case []interface{}:
+ scopes := make([]string, 0, len(v))
+ for _, item := range v {
+ if s, ok := item.(string); ok && s != "" {
+ scopes = append(scopes, s)
+ }
+ }
+ return scopes
+ default:
+ return nil
+ }
+}
+
+func (m *MCPProxy) toolMatches(backendName, toolName string, tools []compiledToolCall, args any) bool {
+ if len(tools) == 0 {
+ return true
+ }
+
+ for _, t := range tools {
+ if t.BackendName != backendName || t.ToolName != toolName {
+ continue
+ }
+ if t.program == nil {
+ return true
+ }
+
+ result, _, err := t.program.Eval(map[string]any{"args": args})
+ if err != nil {
+ m.l.Error("failed to evaluate arguments CEL", slog.String("backend", t.BackendName), slog.String("tool", t.ToolName), slog.String("error", err.Error()))
+ continue
+ }
+
+ switch v := result.Value().(type) {
+ case bool:
+ if v {
+ return true
+ }
+ case types.Bool:
+ if bool(v) {
+ return true
+ }
+ default:
+ m.l.Error("arguments CEL did not return a boolean", slog.String("backend", t.BackendName), slog.String("tool", t.ToolName), slog.String("expression", t.Expression))
+ }
+ }
+ // If no matching tool entry or no arguments matched, fail.
+ return false
+}
+
+func scopesSatisfied(have sets.Set[string], required []string) bool {
+ if len(required) == 0 {
+ return true
+ }
+ // All required scopes must be present for authorization to succeed.
+ for _, scope := range required {
+ if _, ok := have[scope]; !ok {
+ return false
+ }
+ }
+ return true
+}
+
+// buildInsufficientScopeHeader builds the WWW-Authenticate header value for insufficient scope errors.
+// Reference: https://mcp.mintlify.app/specification/2025-11-25/basic/authorization#runtime-insufficient-scope-errors
+func buildInsufficientScopeHeader(scopes []string, resourceMetadata string) string {
+ parts := []string{`Bearer error="insufficient_scope"`}
+ parts = append(parts, fmt.Sprintf(`scope="%s"`, strings.Join(scopes, " ")))
+ if resourceMetadata != "" {
+ parts = append(parts, fmt.Sprintf(`resource_metadata="%s"`, resourceMetadata))
+ }
+ parts = append(parts, `error_description="The token is missing required scopes"`)
+
+ return strings.Join(parts, ", ")
+}
diff --git a/internal/mcpproxy/authorization_test.go b/internal/mcpproxy/authorization_test.go
new file mode 100644
index 0000000000..11a3512281
--- /dev/null
+++ b/internal/mcpproxy/authorization_test.go
@@ -0,0 +1,472 @@
+// Copyright Envoy AI Gateway Authors
+// SPDX-License-Identifier: Apache-2.0
+// The full text of the Apache license is available in the LICENSE file at
+// the root of the repo.
+
+package mcpproxy
+
+import (
+ "io"
+ "log/slog"
+ "net/http"
+ "reflect"
+ "testing"
+
+ "github.com/golang-jwt/jwt/v5"
+
+ "github.com/envoyproxy/ai-gateway/internal/filterapi"
+)
+
+func strPtr(s string) *string { return &s }
+
+func TestAuthorizeRequest(t *testing.T) {
+ makeToken := func(scopes ...string) string {
+ claims := jwt.MapClaims{}
+ if len(scopes) > 0 {
+ claims["scope"] = scopes
+ }
+ token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
+ signed, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
+ return signed
+ }
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ proxy := &MCPProxy{l: logger}
+
+ tests := []struct {
+ name string
+ auth *filterapi.MCPRouteAuthorization
+ header string
+ backendName string
+ toolName string
+ args map[string]any
+ expectError bool
+ expectAllowed bool
+ expectScopes []string
+ }{
+ {
+ name: "matching tool and scope",
+ auth: &filterapi.MCPRouteAuthorization{
+ Rules: []filterapi.MCPRouteAuthorizationRule{
+ {
+ Source: filterapi.MCPAuthorizationSource{
+ JWTSource: filterapi.JWTSource{Scopes: []string{"read", "write"}},
+ },
+ Target: filterapi.MCPAuthorizationTarget{
+ Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}},
+ },
+ },
+ },
+ },
+ header: "Bearer " + makeToken("read", "write"),
+ backendName: "backend1",
+ toolName: "tool1",
+ expectAllowed: true,
+ expectScopes: nil,
+ },
+ {
+ name: "matching tool scope and arguments CEL",
+ auth: &filterapi.MCPRouteAuthorization{
+ Rules: []filterapi.MCPRouteAuthorizationRule{
+ {
+ Source: filterapi.MCPAuthorizationSource{
+ JWTSource: filterapi.JWTSource{Scopes: []string{"read"}},
+ },
+ Target: filterapi.MCPAuthorizationTarget{
+ Tools: []filterapi.ToolCall{{
+ BackendName: "backend1",
+ ToolName: "tool1",
+ Arguments: strPtr(`args.mode in ["fast", "slow"] && args.user.matches("u-[0-9]+") && args.debug == true`),
+ }},
+ },
+ },
+ },
+ },
+ header: "Bearer " + makeToken("read"),
+ backendName: "backend1",
+ toolName: "tool1",
+ args: map[string]any{
+ "mode": "fast",
+ "user": "u-123",
+ "debug": true,
+ },
+ expectAllowed: true,
+ expectScopes: nil,
+ },
+ {
+ name: "numeric argument matches via CEL",
+ auth: &filterapi.MCPRouteAuthorization{
+ Rules: []filterapi.MCPRouteAuthorizationRule{
+ {
+ Source: filterapi.MCPAuthorizationSource{
+ JWTSource: filterapi.JWTSource{Scopes: []string{"read"}},
+ },
+ Target: filterapi.MCPAuthorizationTarget{
+ Tools: []filterapi.ToolCall{{
+ BackendName: "backend1",
+ ToolName: "tool1",
+ Arguments: strPtr(`int(args.count) >= 40 && int(args.count) < 50`),
+ }},
+ },
+ },
+ },
+ },
+ header: "Bearer " + makeToken("read"),
+ backendName: "backend1",
+ toolName: "tool1",
+ args: map[string]any{"count": 42},
+ expectAllowed: true,
+ expectScopes: nil,
+ },
+ {
+ name: "object argument can be matched via CEL safe navigation",
+ auth: &filterapi.MCPRouteAuthorization{
+ Rules: []filterapi.MCPRouteAuthorizationRule{
+ {
+ Source: filterapi.MCPAuthorizationSource{
+ JWTSource: filterapi.JWTSource{Scopes: []string{"read"}},
+ },
+ Target: filterapi.MCPAuthorizationTarget{
+ Tools: []filterapi.ToolCall{{
+ BackendName: "backend1",
+ ToolName: "tool1",
+ Arguments: strPtr(`args["payload"] != null && args["payload"]["kind"] == "test" && args["payload"]["value"] == 123`),
+ }},
+ },
+ },
+ },
+ },
+ header: "Bearer " + makeToken("read"),
+ backendName: "backend1",
+ toolName: "tool1",
+ args: map[string]any{
+ "payload": map[string]any{
+ "kind": "test",
+ "value": 123,
+ },
+ },
+ expectAllowed: true,
+ expectScopes: nil,
+ },
+ {
+ name: "matching tool but insufficient scopes not allowed",
+ auth: &filterapi.MCPRouteAuthorization{
+ Rules: []filterapi.MCPRouteAuthorizationRule{
+ {
+ Source: filterapi.MCPAuthorizationSource{
+ JWTSource: filterapi.JWTSource{Scopes: []string{"read", "write"}},
+ },
+ Target: filterapi.MCPAuthorizationTarget{
+ Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}},
+ },
+ },
+ },
+ },
+ header: "Bearer " + makeToken("read"),
+ backendName: "backend1",
+ toolName: "tool1",
+ expectAllowed: false,
+ expectScopes: []string{"read", "write"},
+ },
+ {
+ name: "arguments CEL mismatch denied",
+ auth: &filterapi.MCPRouteAuthorization{
+ Rules: []filterapi.MCPRouteAuthorizationRule{
+ {
+ Source: filterapi.MCPAuthorizationSource{
+ JWTSource: filterapi.JWTSource{Scopes: []string{"read"}},
+ },
+ Target: filterapi.MCPAuthorizationTarget{
+ Tools: []filterapi.ToolCall{{
+ BackendName: "backend1",
+ ToolName: "tool1",
+ Arguments: strPtr(`args.mode in ["fast", "slow"]`),
+ }},
+ },
+ },
+ },
+ },
+ header: "Bearer " + makeToken("read"),
+ backendName: "backend1",
+ toolName: "tool1",
+ args: map[string]any{
+ "mode": "other",
+ },
+ expectAllowed: false,
+ expectScopes: nil,
+ },
+ {
+ name: "arguments CEL failed evaluation denies",
+ auth: &filterapi.MCPRouteAuthorization{
+ Rules: []filterapi.MCPRouteAuthorizationRule{
+ {
+ Source: filterapi.MCPAuthorizationSource{
+ JWTSource: filterapi.JWTSource{Scopes: []string{"read"}},
+ },
+ Target: filterapi.MCPAuthorizationTarget{
+ Tools: []filterapi.ToolCall{{
+ BackendName: "backend1",
+ ToolName: "tool1",
+ Arguments: strPtr(`args.nonExistingField in ["fast", "slow"]`),
+ }},
+ },
+ },
+ },
+ },
+ header: "Bearer " + makeToken("read"),
+ backendName: "backend1",
+ toolName: "tool1",
+ args: map[string]any{
+ "mode": "other",
+ },
+ expectAllowed: false,
+ expectScopes: nil,
+ },
+ {
+ name: "arguments CEL returns non-boolean denies",
+ auth: &filterapi.MCPRouteAuthorization{
+ Rules: []filterapi.MCPRouteAuthorizationRule{
+ {
+ Source: filterapi.MCPAuthorizationSource{
+ JWTSource: filterapi.JWTSource{Scopes: []string{"read"}},
+ },
+ Target: filterapi.MCPAuthorizationTarget{
+ Tools: []filterapi.ToolCall{{
+ BackendName: "backend1",
+ ToolName: "tool1",
+ Arguments: strPtr(`args.mode`),
+ }},
+ },
+ },
+ },
+ },
+ header: "Bearer " + makeToken("read"),
+ backendName: "backend1",
+ toolName: "tool1",
+ args: map[string]any{
+ "mode": "other",
+ },
+ expectAllowed: false,
+ expectScopes: nil,
+ },
+ {
+ name: "arguments invalid CEL denies",
+ auth: &filterapi.MCPRouteAuthorization{
+ Rules: []filterapi.MCPRouteAuthorizationRule{
+ {
+ Source: filterapi.MCPAuthorizationSource{
+ JWTSource: filterapi.JWTSource{Scopes: []string{"read"}},
+ },
+ Target: filterapi.MCPAuthorizationTarget{
+ Tools: []filterapi.ToolCall{{
+ BackendName: "backend1",
+ ToolName: "tool1",
+ Arguments: strPtr(`invalid syntax here`),
+ }},
+ },
+ },
+ },
+ },
+ header: "Bearer " + makeToken("read"),
+ backendName: "backend1",
+ toolName: "tool1",
+ args: map[string]any{
+ "mode": "other",
+ },
+ expectError: true,
+ expectAllowed: false,
+ expectScopes: nil,
+ },
+ {
+ name: "missing argument denies when required",
+ auth: &filterapi.MCPRouteAuthorization{
+ Rules: []filterapi.MCPRouteAuthorizationRule{
+ {
+ Source: filterapi.MCPAuthorizationSource{
+ JWTSource: filterapi.JWTSource{Scopes: []string{"read"}},
+ },
+ Target: filterapi.MCPAuthorizationTarget{
+ Tools: []filterapi.ToolCall{{
+ BackendName: "backend1",
+ ToolName: "tool1",
+ Arguments: strPtr(`args["mode"] == "fast"`),
+ }},
+ },
+ },
+ },
+ },
+ header: "Bearer " + makeToken("read"),
+ backendName: "backend1",
+ toolName: "tool1",
+ args: map[string]any{},
+ expectAllowed: false,
+ expectScopes: nil,
+ },
+ {
+ name: "no matching rule falls back to default deny - tool mismatch",
+ auth: &filterapi.MCPRouteAuthorization{
+ Rules: []filterapi.MCPRouteAuthorizationRule{
+ {
+ Source: filterapi.MCPAuthorizationSource{
+ JWTSource: filterapi.JWTSource{Scopes: []string{"read"}},
+ },
+ Target: filterapi.MCPAuthorizationTarget{
+ Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}},
+ },
+ },
+ },
+ },
+ header: "Bearer " + makeToken("read", "write"),
+ backendName: "backend1",
+ toolName: "other-tool",
+ expectAllowed: false,
+ expectScopes: nil,
+ },
+ {
+ name: "no matching rule falls back to default deny - scope mismatch",
+ auth: &filterapi.MCPRouteAuthorization{
+ Rules: []filterapi.MCPRouteAuthorizationRule{
+ {
+ Source: filterapi.MCPAuthorizationSource{
+ JWTSource: filterapi.JWTSource{Scopes: []string{"read"}},
+ },
+ Target: filterapi.MCPAuthorizationTarget{
+ Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}},
+ },
+ },
+ },
+ },
+ header: "Bearer " + makeToken("foo", "bar"),
+ backendName: "backend1",
+ toolName: "other-tool",
+ expectAllowed: false,
+ expectScopes: nil,
+ },
+ {
+ name: "no rules falls back to default deny",
+ auth: &filterapi.MCPRouteAuthorization{},
+ header: "",
+ backendName: "backend1",
+ toolName: "tool1",
+ expectAllowed: false,
+ expectScopes: nil,
+ },
+ {
+ name: "no bearer token not allowed when rules exist",
+ auth: &filterapi.MCPRouteAuthorization{
+ Rules: []filterapi.MCPRouteAuthorizationRule{
+ {
+ Source: filterapi.MCPAuthorizationSource{
+ JWTSource: filterapi.JWTSource{Scopes: []string{"read"}},
+ },
+ Target: filterapi.MCPAuthorizationTarget{
+ Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}},
+ },
+ },
+ },
+ },
+ header: "",
+ backendName: "backend1",
+ toolName: "tool1",
+ expectAllowed: false,
+ expectScopes: nil,
+ },
+ {
+ name: "invalid bearer token not allowed when rules exist",
+ auth: &filterapi.MCPRouteAuthorization{
+ Rules: []filterapi.MCPRouteAuthorizationRule{
+ {
+ Source: filterapi.MCPAuthorizationSource{
+ JWTSource: filterapi.JWTSource{Scopes: []string{"read"}},
+ },
+ Target: filterapi.MCPAuthorizationTarget{
+ Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}},
+ },
+ },
+ },
+ },
+ header: "Bearer invalid.token.here",
+ backendName: "backend1",
+ toolName: "tool1",
+ expectAllowed: false,
+ expectScopes: nil,
+ },
+ {
+ name: "selects smallest required scope set when multiple rules match",
+ auth: &filterapi.MCPRouteAuthorization{
+ Rules: []filterapi.MCPRouteAuthorizationRule{
+ {
+ Source: filterapi.MCPAuthorizationSource{JWTSource: filterapi.JWTSource{Scopes: []string{"alpha", "beta", "gamma"}}},
+ Target: filterapi.MCPAuthorizationTarget{Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}},
+ },
+ {
+ Source: filterapi.MCPAuthorizationSource{JWTSource: filterapi.JWTSource{Scopes: []string{"alpha", "beta"}}},
+ Target: filterapi.MCPAuthorizationTarget{Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}},
+ },
+ },
+ },
+ header: "Bearer " + makeToken("alpha"),
+ backendName: "backend1",
+ toolName: "tool1",
+ expectAllowed: false,
+ expectScopes: []string{"alpha", "beta"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ headers := http.Header{}
+ if tt.header != "" {
+ headers.Set("Authorization", tt.header)
+ }
+ compiled, err := compileAuthorization(tt.auth)
+ if (err != nil) != tt.expectError {
+ t.Fatalf("expected error: %v, got: %v", tt.expectError, err)
+ }
+ if err != nil {
+ return
+ }
+ allowed, requiredScopes := proxy.authorizeRequest(compiled, headers, tt.backendName, tt.toolName, tt.args)
+ if allowed != tt.expectAllowed {
+ t.Fatalf("expected %v, got %v", tt.expectAllowed, allowed)
+ }
+ if !reflect.DeepEqual(requiredScopes, tt.expectScopes) {
+ t.Fatalf("expected required scopes %v, got %v", tt.expectScopes, requiredScopes)
+ }
+ })
+ }
+}
+
+func TestBuildInsufficientScopeHeader(t *testing.T) {
+ const resourceMetadata = "https://api.example.com/.well-known/oauth-protected-resource/mcp"
+
+ t.Run("with scopes and resource metadata", func(t *testing.T) {
+ header := buildInsufficientScopeHeader([]string{"read", "write"}, resourceMetadata)
+ expected := `Bearer error="insufficient_scope", scope="read write", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp", error_description="The token is missing required scopes"`
+ if header != expected {
+ t.Fatalf("expected %q, got %q", expected, header)
+ }
+ })
+}
+
+func TestCompileAuthorizationInvalidExpression(t *testing.T) {
+ _, err := compileAuthorization(&filterapi.MCPRouteAuthorization{
+ Rules: []filterapi.MCPRouteAuthorizationRule{
+ {
+ Source: filterapi.MCPAuthorizationSource{
+ JWTSource: filterapi.JWTSource{Scopes: []string{"read"}},
+ },
+ Target: filterapi.MCPAuthorizationTarget{
+ Tools: []filterapi.ToolCall{{
+ BackendName: "backend1",
+ ToolName: "tool1",
+ Arguments: strPtr("args."),
+ }},
+ },
+ },
+ },
+ })
+ if err == nil {
+ t.Fatalf("expected compile error for invalid CEL expression")
+ }
+}
diff --git a/internal/mcpproxy/handlers.go b/internal/mcpproxy/handlers.go
index 34504cf1ab..aad3abf0b9 100644
--- a/internal/mcpproxy/handlers.go
+++ b/internal/mcpproxy/handlers.go
@@ -22,7 +22,7 @@ import (
"strings"
"time"
- "github.com/golang-jwt/jwt/v4"
+ "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
"github.com/modelcontextprotocol/go-sdk/mcp"
@@ -282,7 +282,7 @@ func (m *MCPProxy) servePOST(w http.ResponseWriter, r *http.Request) {
onErrorResponse(w, http.StatusBadRequest, "invalid params")
return
}
- err = m.handleToolCallRequest(ctx, s, w, msg, params.(*mcp.CallToolParams), span)
+ err = m.handleToolCallRequest(ctx, s, w, msg, params.(*mcp.CallToolParams), span, r.Header)
case "tools/list":
params = &mcp.ListToolsParams{}
span, err = parseParamsAndMaybeStartSpan(ctx, m, msg, params, r.Header)
@@ -514,7 +514,7 @@ func (m *MCPProxy) handleClientToServerResponse(ctx context.Context, s *session,
return nil
}
-func (m *MCPProxy) handleToolCallRequest(ctx context.Context, s *session, w http.ResponseWriter, req *jsonrpc.Request, p *mcp.CallToolParams, span tracing.MCPSpan) error {
+func (m *MCPProxy) handleToolCallRequest(ctx context.Context, s *session, w http.ResponseWriter, req *jsonrpc.Request, p *mcp.CallToolParams, span tracing.MCPSpan, headers http.Header) error {
backendName, toolName, err := upstreamResourceName(p.Name)
if err != nil {
onErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("invalid tool name %s: %v", p.Name, err))
@@ -540,6 +540,22 @@ func (m *MCPProxy) handleToolCallRequest(ctx context.Context, s *session, w http
return fmt.Errorf("%w: %s", errInvalidToolName, toolName)
}
+ // Enforce authentication if required by the route.
+ if route.authorization != nil {
+ allowed, requiredScopes := m.authorizeRequest(route.authorization, headers, backendName, toolName, p.Arguments)
+ if !allowed {
+ // Specify the minimum required scopes in the WWW-Authenticate header.
+ // Reference: https://mcp.mintlify.app/specification/2025-11-25/basic/authorization#runtime-insufficient-scope-errors
+ if len(requiredScopes) > 0 {
+ if challenge := buildInsufficientScopeHeader(requiredScopes, route.authorization.ResourceMetadataURL); challenge != "" {
+ w.Header().Set("WWW-Authenticate", challenge)
+ }
+ }
+ onErrorResponse(w, http.StatusForbidden, "authorization failed")
+ return fmt.Errorf("authorization failed")
+ }
+ }
+
cse := s.getCompositeSessionEntry(backendName)
if cse == nil {
onErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("no MCP session found for backend %s", backendName))
diff --git a/internal/mcpproxy/handlers_test.go b/internal/mcpproxy/handlers_test.go
index f7f362dce2..956e7ea45d 100644
--- a/internal/mcpproxy/handlers_test.go
+++ b/internal/mcpproxy/handlers_test.go
@@ -680,7 +680,7 @@ func TestHandleToolCallRequest_UnknownBackend(t *testing.T) {
params := &mcp.CallToolParams{Name: "unknown-backend__unknown-tool"}
rr := httptest.NewRecorder()
- err := proxy.handleToolCallRequest(t.Context(), s, rr, &jsonrpc.Request{}, params, nil)
+ err := proxy.handleToolCallRequest(t.Context(), s, rr, &jsonrpc.Request{}, params, nil, http.Header{})
require.Error(t, err)
require.Equal(t, http.StatusNotFound, rr.Code)
@@ -710,7 +710,7 @@ func TestHandleToolCallRequest_BackendError(t *testing.T) {
params := &mcp.CallToolParams{Name: "backend1__test-tool"}
rr := httptest.NewRecorder()
- err := proxy.handleToolCallRequest(t.Context(), s, rr, &jsonrpc.Request{}, params, nil)
+ err := proxy.handleToolCallRequest(t.Context(), s, rr, &jsonrpc.Request{}, params, nil, http.Header{})
require.Error(t, err)
require.Equal(t, http.StatusInternalServerError, rr.Code)
diff --git a/internal/mcpproxy/mcpproxy.go b/internal/mcpproxy/mcpproxy.go
index 818d3918e6..861dc47135 100644
--- a/internal/mcpproxy/mcpproxy.go
+++ b/internal/mcpproxy/mcpproxy.go
@@ -53,6 +53,7 @@ type (
mcpProxyConfigRoute struct {
backends map[filterapi.MCPBackendName]filterapi.MCPBackend
toolSelectors map[filterapi.MCPBackendName]*toolSelector
+ authorization *compiledAuthorization
}
// toolSelector filters tools using include patterns with exact matches or regular expressions.
@@ -132,9 +133,15 @@ func (p *ProxyConfig) LoadConfig(_ context.Context, config *filterapi.Config) er
newConfig.routes = make(map[filterapi.MCPRouteName]*mcpProxyConfigRoute, len(mcpConfig.Routes))
for _, route := range mcpConfig.Routes {
+ compiledAuth, err := compileAuthorization(route.Authorization)
+ if err != nil {
+ return fmt.Errorf("failed to compile authorization rules for route %s: %w", route.Name, err)
+ }
+
r := &mcpProxyConfigRoute{
backends: make(map[filterapi.MCPBackendName]filterapi.MCPBackend, len(route.Backends)),
toolSelectors: make(map[filterapi.MCPBackendName]*toolSelector, len(route.Backends)),
+ authorization: compiledAuth,
}
for _, backend := range route.Backends {
r.backends[backend.Name] = backend
diff --git a/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_mcproutes.yaml b/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_mcproutes.yaml
index 1734e611be..a0e24582ee 100644
--- a/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_mcproutes.yaml
+++ b/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_mcproutes.yaml
@@ -581,6 +581,88 @@ spec:
- credentialRefs
- extractFrom
type: object
+ authorization:
+ description: Authorization defines the configuration for the MCP
+ spec compatible authorization.
+ properties:
+ rules:
+ description: |-
+ Rules defines a list of authorization rules.
+
+ Requests that match any rule and satisfy the rule's conditions will be allowed.
+ Requests that do not match any rule or fail to satisfy the matched rule's conditions will be denied.
+ If no rules are defined, all requests will be denied.
+ items:
+ description: |-
+ MCPRouteAuthorizationRule defines an authorization rule for MCPRoute based on the MCP authorization spec.
+ Reference: https://modelcontextprotocol.io/specification/draft/basic/authorization#scope-challenge-handling
+ properties:
+ source:
+ description: Source defines the authorization source
+ for this rule.
+ properties:
+ jwtSource:
+ description: JWTSource defines the JWT scopes required
+ for this rule to match.
+ properties:
+ scopes:
+ description: |-
+ Scopes defines the list of JWT scopes required for the rule.
+ If multiple scopes are specified, all scopes must be present in the JWT for the rule to match.
+ items:
+ maxLength: 253
+ minLength: 1
+ type: string
+ maxItems: 16
+ minItems: 1
+ type: array
+ required:
+ - scopes
+ type: object
+ required:
+ - jwtSource
+ type: object
+ target:
+ description: Target defines the authorization target
+ for this rule.
+ properties:
+ tools:
+ description: Tools defines the list of tools this
+ rule applies to.
+ items:
+ description: ToolCall represents a tool call in
+ the MCP authorization target.
+ properties:
+ arguments:
+ description: |-
+ Arguments is a CEL expression that must evaluate to true for the rule to match.
+ The expression is evaluated with a single variable "args" bound to the tool call arguments as a dynamic object.
+ Guard against missing fields with null checks (e.g., args["foo"] != null && args["foo"]["bar"] == "val").
+ maxLength: 4096
+ type: string
+ backendName:
+ description: BackendName is the name of the
+ backend this tool belongs to.
+ type: string
+ toolName:
+ description: ToolName is the name of the tool.
+ type: string
+ required:
+ - backendName
+ - toolName
+ type: object
+ maxItems: 16
+ minItems: 1
+ type: array
+ required:
+ - tools
+ type: object
+ required:
+ - source
+ - target
+ type: object
+ type: array
+ type: object
extAuth:
description: ExtAuth defines the configuration for External Authorization.
properties:
@@ -4126,6 +4208,9 @@ spec:
- protectedResourceMetadata
type: object
type: object
+ x-kubernetes-validations:
+ - message: oauth must be configured when authorization is set
+ rule: '!has(self.authorization) || has(self.oauth)'
required:
- backendRefs
- parentRefs
diff --git a/site/docs/api/api.mdx b/site/docs/api/api.mdx
index 66fb5147ca..da92294ad4 100644
--- a/site/docs/api/api.mdx
+++ b/site/docs/api/api.mdx
@@ -404,10 +404,15 @@ MCPRouteList contains a list of MCPRoute.
- [HTTPBodyMutation](#httpbodymutation)
- [HTTPHeaderMutation](#httpheadermutation)
- [JWKS](#jwks)
+- [JWTSource](#jwtsource)
- [LLMRequestCost](#llmrequestcost)
- [LLMRequestCostType](#llmrequestcosttype)
+- [MCPAuthorizationSource](#mcpauthorizationsource)
+- [MCPAuthorizationTarget](#mcpauthorizationtarget)
- [MCPBackendAPIKey](#mcpbackendapikey)
- [MCPBackendSecurityPolicy](#mcpbackendsecuritypolicy)
+- [MCPRouteAuthorization](#mcprouteauthorization)
+- [MCPRouteAuthorizationRule](#mcprouteauthorizationrule)
- [MCPRouteBackendRef](#mcproutebackendref)
- [MCPRouteOAuth](#mcprouteoauth)
- [MCPRouteSecurityPolicy](#mcproutesecuritypolicy)
@@ -415,6 +420,7 @@ MCPRouteList contains a list of MCPRoute.
- [MCPRouteStatus](#mcproutestatus)
- [MCPToolFilter](#mcptoolfilter)
- [ProtectedResourceMetadata](#protectedresourcemetadata)
+- [ToolCall](#toolcall)
- [VersionedAPISchema](#versionedapischema)
### Type Definitions
@@ -1443,6 +1449,27 @@ JWKS defines how to obtain JSON Web Key Sets (JWKS) either from a remote HTTP/HT
/>
+#### JWTSource
+
+
+
+**Appears in:**
+- [MCPAuthorizationSource](#mcpauthorizationsource)
+
+JWTSource defines the MCP authorization source for JWT tokens.
+
+##### Fields
+
+
+
+
+
+
#### LLMRequestCost
@@ -1513,6 +1540,48 @@ LLMRequestCostType specifies the type of the LLMRequestCost.
required="false"
description="LLMRequestCostTypeCEL is for calculating the cost using the CEL expression.
"
/>
+#### MCPAuthorizationSource
+
+
+
+**Appears in:**
+- [MCPRouteAuthorizationRule](#mcprouteauthorizationrule)
+
+MCPAuthorizationSource defines the source of an authorization rule.
+
+##### Fields
+
+
+
+
+
+
+#### MCPAuthorizationTarget
+
+
+
+**Appears in:**
+- [MCPRouteAuthorizationRule](#mcprouteauthorizationrule)
+
+MCPAuthorizationTarget defines the target of an authorization rule.
+
+##### Fields
+
+
+
+
+
+
#### MCPBackendAPIKey
@@ -1565,6 +1634,54 @@ MCPBackendSecurityPolicy defines the security policy for a sp
/>
+#### MCPRouteAuthorization
+
+
+
+**Appears in:**
+- [MCPRouteSecurityPolicy](#mcproutesecuritypolicy)
+
+MCPRouteAuthorization defines the authorization configuration for a MCPRoute.
+
+##### Fields
+
+
+
+
+
+
+#### MCPRouteAuthorizationRule
+
+
+
+**Appears in:**
+- [MCPRouteAuthorization](#mcprouteauthorization)
+
+MCPRouteAuthorizationRule defines an authorization rule for MCPRoute based on the MCP authorization spec.
+Reference: https://modelcontextprotocol.io/specification/draft/basic/authorization#scope-challenge-handling
+
+##### Fields
+
+
+
+
+
+
#### MCPRouteBackendRef
@@ -1688,6 +1805,11 @@ MCPRouteSecurityPolicy defines the security policy for a MCPRoute.
type="[ExtAuth](#extauth)"
required="false"
description="ExtAuth defines the configuration for External Authorization."
+/>
@@ -1830,6 +1952,37 @@ References:
/>
+#### ToolCall
+
+
+
+**Appears in:**
+- [MCPAuthorizationTarget](#mcpauthorizationtarget)
+
+ToolCall represents a tool call in the MCP authorization target.
+
+##### Fields
+
+
+
+
+
+
#### VersionedAPISchema
diff --git a/tests/crdcel/main_test.go b/tests/crdcel/main_test.go
index ced9b33998..04e9fb9951 100644
--- a/tests/crdcel/main_test.go
+++ b/tests/crdcel/main_test.go
@@ -250,6 +250,10 @@ func TestMCPRoutes(t *testing.T) {
name: "jwks_both.yaml",
expErr: "spec.securityPolicy.oauth.jwks: Invalid value: \"object\": remoteJWKS and localJWKS cannot both be specified.",
},
+ {
+ name: "authorization_without_oauth.yaml",
+ expErr: "spec.securityPolicy: Invalid value: \"object\": oauth must be configured when authorization is set",
+ },
} {
t.Run(tc.name, func(t *testing.T) {
data, err := testdata.ReadFile(path.Join("testdata/mcpgatewayroutes", tc.name))
diff --git a/tests/crdcel/testdata/mcpgatewayroutes/authorization_without_oauth.yaml b/tests/crdcel/testdata/mcpgatewayroutes/authorization_without_oauth.yaml
new file mode 100644
index 0000000000..e5b26ce6a8
--- /dev/null
+++ b/tests/crdcel/testdata/mcpgatewayroutes/authorization_without_oauth.yaml
@@ -0,0 +1,30 @@
+# Copyright Envoy AI Gateway Authors
+# SPDX-License-Identifier: Apache-2.0
+# The full text of the Apache license is available in the LICENSE file at
+# the root of the repo.
+
+apiVersion: aigateway.envoyproxy.io/v1alpha1
+kind: MCPRoute
+metadata:
+ name: authorization-without-oauth
+ namespace: default
+spec:
+ parentRefs:
+ - name: some-gateway
+ kind: Gateway
+ group: gateway.networking.k8s.io
+ backendRefs:
+ - name: mcp-service
+ kind: Service
+ port: 80
+ securityPolicy:
+ authorization:
+ rules:
+ - source:
+ jwtSource:
+ scopes:
+ - echo
+ target:
+ tools:
+ - backendName: mcp-service
+ toolName: echo
diff --git a/tests/e2e/mcp_route_authorization_test.go b/tests/e2e/mcp_route_authorization_test.go
new file mode 100644
index 0000000000..e660631828
--- /dev/null
+++ b/tests/e2e/mcp_route_authorization_test.go
@@ -0,0 +1,285 @@
+// Copyright Envoy AI Gateway Authors
+// SPDX-License-Identifier: Apache-2.0
+// The full text of the Apache license is available in the LICENSE file at
+// the root of the repo.
+
+package e2e
+
+import (
+ "bytes"
+ "context"
+ "crypto/rsa"
+ "encoding/base64"
+ "fmt"
+ "math/big"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/stretchr/testify/require"
+
+ "github.com/envoyproxy/ai-gateway/tests/internal/e2elib"
+ "github.com/envoyproxy/ai-gateway/tests/internal/testmcp"
+)
+
+// bearerTokenTransport injects a bearer token into outgoing requests.
+type bearerTokenTransport struct {
+ token string
+ base http.RoundTripper
+}
+
+// RoundTrip implements [http.RoundTripper.RoundTrip].
+func (t *bearerTokenTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ base := t.base
+ if base == nil {
+ base = http.DefaultTransport
+ }
+ req.Header.Set("Authorization", "Bearer "+t.token)
+ return base.RoundTrip(req)
+}
+
+func TestMCPRouteAuthorization(t *testing.T) {
+ const manifest = "testdata/mcp_route_authorization.yaml"
+ require.NoError(t, e2elib.KubectlApplyManifest(t.Context(), manifest))
+ t.Cleanup(func() {
+ _ = e2elib.KubectlDeleteManifest(context.Background(), manifest)
+ })
+
+ const egSelector = "gateway.envoyproxy.io/owning-gateway-name=mcp-gateway-authorization"
+ e2elib.RequireWaitForGatewayPodReady(t, egSelector)
+
+ fwd := e2elib.RequireNewHTTPPortForwarder(t, e2elib.EnvoyGatewayNamespace, egSelector, e2elib.EnvoyGatewayDefaultServicePort)
+ defer fwd.Kill()
+
+ client := mcp.NewClient(&mcp.Implementation{Name: "demo-http-client", Version: "0.1.0"}, nil)
+
+ t.Run("allow rules with matching scopes", func(t *testing.T) {
+ token := makeSignedJWT(t, "sum")
+ authHTTPClient := &http.Client{
+ Timeout: 10 * time.Second,
+ Transport: &bearerTokenTransport{
+ token: token,
+ },
+ }
+
+ ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
+ t.Cleanup(cancel)
+
+ sess := requireConnectMCP(ctx, t, client, fmt.Sprintf("%s/mcp-authorization", fwd.Address()), authHTTPClient)
+ t.Cleanup(func() {
+ _ = sess.Close()
+ })
+
+ res, err := sess.CallTool(ctx, &mcp.CallToolParams{
+ Name: "mcp-backend-authorization__" + testmcp.ToolSum.Tool.Name,
+ Arguments: testmcp.ToolSumArgs{A: 41, B: 1},
+ })
+ require.NoError(t, err)
+ require.False(t, res.IsError)
+ require.Len(t, res.Content, 1)
+ txt, ok := res.Content[0].(*mcp.TextContent)
+ require.True(t, ok)
+ require.Equal(t, "42", txt.Text)
+ })
+
+ t.Run("matching scopes and arguments", func(t *testing.T) {
+ token := makeSignedJWT(t, "echo")
+ authHTTPClient := &http.Client{
+ Timeout: 10 * time.Second,
+ Transport: &bearerTokenTransport{
+ token: token,
+ },
+ }
+
+ ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
+ t.Cleanup(cancel)
+
+ sess := requireConnectMCP(ctx, t, client, fmt.Sprintf("%s/mcp-authorization", fwd.Address()), authHTTPClient)
+ t.Cleanup(func() {
+ _ = sess.Close()
+ })
+
+ const hello = "Hello, world!" // Should match the argument regex "^Hello, .*!$"
+ res, err := sess.CallTool(ctx, &mcp.CallToolParams{
+ Name: "mcp-backend-authorization__" + testmcp.ToolEcho.Tool.Name,
+ Arguments: testmcp.ToolEchoArgs{Text: hello},
+ })
+ require.NoError(t, err)
+ require.False(t, res.IsError)
+ require.Len(t, res.Content, 1)
+ txt, ok := res.Content[0].(*mcp.TextContent)
+ require.True(t, ok)
+ require.Equal(t, hello, txt.Text)
+ })
+
+ t.Run("matching scopes and mismatched arguments", func(t *testing.T) {
+ token := makeSignedJWT(t, "echo")
+ authHTTPClient := &http.Client{
+ Timeout: 10 * time.Second,
+ Transport: &bearerTokenTransport{
+ token: token,
+ },
+ }
+
+ ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
+ t.Cleanup(cancel)
+
+ sess := requireConnectMCP(ctx, t, client, fmt.Sprintf("%s/mcp-authorization", fwd.Address()), authHTTPClient)
+ t.Cleanup(func() {
+ _ = sess.Close()
+ })
+
+ const hello = "hello, world!" // Should match the argument regex "^Hello, .*!$"
+ _, err := sess.CallTool(ctx, &mcp.CallToolParams{
+ Name: "mcp-backend-authorization__" + testmcp.ToolEcho.Tool.Name,
+ Arguments: testmcp.ToolEchoArgs{Text: hello},
+ })
+ require.Error(t, err)
+ errMsg := strings.ToLower(err.Error())
+ require.True(t, strings.Contains(errMsg, "403") || strings.Contains(errMsg, "authorization"), "unexpected error: %v", err)
+ })
+
+ t.Run("missing scopes fall back to deny", func(t *testing.T) {
+ // Only includes the sum scope, so the echo tool should be denied.
+ token := makeSignedJWT(t, "sum")
+ authHTTPClient := &http.Client{
+ Timeout: 10 * time.Second,
+ Transport: &bearerTokenTransport{
+ token: token,
+ },
+ }
+
+ ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
+ t.Cleanup(cancel)
+
+ sess := requireConnectMCP(ctx, t, client, fmt.Sprintf("%s/mcp-authorization", fwd.Address()), authHTTPClient)
+ t.Cleanup(func() {
+ _ = sess.Close()
+ })
+
+ _, err := sess.CallTool(ctx, &mcp.CallToolParams{
+ Name: "mcp-backend-authorization__" + testmcp.ToolEcho.Tool.Name,
+ Arguments: testmcp.ToolEchoArgs{Text: "hello"},
+ })
+ require.Error(t, err)
+ errMsg := strings.ToLower(err.Error())
+ require.True(t, strings.Contains(errMsg, "403") || strings.Contains(errMsg, "authorization"), "unexpected error: %v", err)
+ })
+
+ t.Run("WWW-Authenticate on insufficient scope", func(t *testing.T) {
+ token := makeSignedJWT(t, "sum") // only sum scope; echo requires echo
+ authHTTPClient := &http.Client{
+ Timeout: 10 * time.Second,
+ Transport: &bearerTokenTransport{
+ token: token,
+ },
+ }
+
+ routeHeader := "default/mcp-route-authorization-default-deny"
+
+ // First, initialize a session to obtain a session ID header.
+ ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
+ t.Cleanup(cancel)
+
+ sess := requireConnectMCP(ctx, t, client, fmt.Sprintf("%s/mcp-authorization", fwd.Address()), authHTTPClient)
+ t.Cleanup(func() {
+ _ = sess.Close()
+ })
+
+ // Now call a tool that requires a missing scope to trigger insufficient_scope.
+ reqBody := []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"mcp-backend-authorization__echo","arguments":{"text":"Hello, world!"}}}`)
+ req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/mcp-authorization", fwd.Address()), bytes.NewReader(reqBody))
+ require.NoError(t, err)
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("mcp-session-id", sess.ID())
+ req.Header.Set("x-ai-eg-mcp-route", routeHeader)
+
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ require.Equal(t, http.StatusForbidden, resp.StatusCode)
+ wwwAuth := resp.Header.Get("WWW-Authenticate")
+ require.Contains(t, wwwAuth, `error="insufficient_scope"`)
+ require.Contains(t, wwwAuth, `scope="echo"`) // expected missing scope
+ require.Contains(t, wwwAuth, `resource_metadata="https://foo.bar.com/.well-known/oauth-protected-resource/mcp"`)
+ })
+}
+
+func requireConnectMCP(ctx context.Context, t *testing.T, client *mcp.Client, endpoint string, httpClient *http.Client) *mcp.ClientSession {
+ var sess *mcp.ClientSession
+ require.Eventually(t, func() bool {
+ var err error
+ sess, err = client.Connect(
+ ctx,
+ &mcp.StreamableClientTransport{
+ Endpoint: endpoint,
+ HTTPClient: httpClient,
+ }, nil)
+ if err != nil {
+ t.Logf("failed to connect to MCP server: %v", err)
+ return false
+ }
+ return true
+ }, 30*time.Second, 100*time.Millisecond, "failed to connect to MCP server")
+ return sess
+}
+
+func makeSignedJWT(t *testing.T, scopes ...string) string {
+ t.Helper()
+
+ claims := jwt.MapClaims{
+ "iss": "https://auth-server.example.com",
+ "aud": "mcp-test",
+ "sub": "robin",
+ "client_id": "my_mcp_gateway",
+ "scope": strings.Join(scopes, " "),
+ "exp": time.Now().Add(30 * time.Minute).Unix(),
+ "iat": time.Now().Unix(),
+ "auth_time": time.Now().Unix(),
+ "token_type": "Bearer",
+ }
+
+ key := jwkPrivateKey(t)
+ token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
+ token.Header["kid"] = jwkPrivateKeyID
+ signed, err := token.SignedString(key)
+ require.NoError(t, err)
+ return signed
+}
+
+func jwkPrivateKey(t *testing.T) *rsa.PrivateKey {
+ t.Helper()
+
+ mustBigInt := func(val string) *big.Int {
+ bytes, err := base64.RawURLEncoding.DecodeString(val)
+ require.NoError(t, err)
+ return new(big.Int).SetBytes(bytes)
+ }
+
+ key := &rsa.PrivateKey{
+ PublicKey: rsa.PublicKey{
+ N: mustBigInt(jwkN),
+ E: 65537, // AQAB
+ },
+ D: mustBigInt(jwkD),
+ Primes: []*big.Int{
+ mustBigInt(jwkP),
+ mustBigInt(jwkQ),
+ },
+ }
+ key.Precompute()
+ return key
+}
+
+const (
+ jwkPrivateKeyID = "8b267675394d7786f98ae29d8fddf905"
+ jwkN = "lQiREO6mH0tV14KjhSz02zNaJ1SExS1QjlGoMSbG2-NUeURmvnwg-eY95sDCFpWuH3_ovFyRBGTi0e3j79mrQhM53PZQys-gr-rYGz8LeHp8G6H3XmeDxTvyemhB6uiN4EZkjOo6xT2ipmPEN3u315xPCR60Pwac2E0t4vZGxtU4LGYatIFOYtUvDdMPBLfGMKVapHBzbx9Ll4INEic1fNrAIUVtOn6i3sxzGHj4iGWsMrEUIXDOWEHzioXgPuRjJDRjhHRuEeA9i_Y-a9hY92q6P-dcPnCDLNF3349WDyw7jIMlU6TLM8lQ5ci_TS_0avovXPNECsuOObtT78LJJKLg58ghTnqrihwvSccVgW4M43Ntv7TOAgOsRl-NKY7QQJIbkxemvh14-gzwA6LijMvb0Tjrh6NynKfCIO0ASsMp3K3uks4cYhBALLJ1E41V-cYqdwg0c6Jam0Y4OXxNv_0FfmcsOk8iXdroNgWjBs3KaObMiMvNOKHNWZ4PsEll"
+ jwkD = "CCv3lFeZmUautsntgGxmIqzOqTBrtUoWTC9zCvrm1YDCDYIwJgq1Xi5_P2tbWRSs_wIq90UWGIkVnNAv-uNTDiTyu8hvxqca1vqIDfpnfRwuOO-pGi6P3Z07XvXfg2tr-Bu0ALwJK-6EwB3hUO-CNZrXBJd_56LLr9qPhQ3e9KEVWu3gUfxzGV06HsZvYOFYxysR7MlTswiiwvR5FgE7YBS4izp80kPGV3QbbYCYlBYLGp52DZ1bWyCGo5ZSpPAt4Az9wdDTzJoTtflLymg8kZ-idQqk2_re214xQgeCuVAHujjC4r3GqSzbQGUqXicd-rbRLenyB22Ul8wyHqY8WtcFrGmHojK8b-W3M9m0-xYkMXmWcllYQuQ0LMP9K8Tl0uMpKsyd0AePItaWa_ft3dAzoBiUZA15X2_Nbbc9WbkmjN0Et8E1RWlrL5fzppbvLUl4mlSKHsLnwgmLx2OROjEnQsfzjMGxV2KhMZXzdvbRPTkaDtq3YT70ZiRIyvRD"
+ jwkP = "yO5hho-83vQQ3t7HeVeinZClemDazWT5T7f2ZVMigcuyUNQjC69tyMzJ3I_UN5nUCwpKCw5wY8uCeT82o1j-OJC3irxWjAPHkkbsYTNxRnk8ShJ2UFdu5a7MEF82-QuRKciAv11cebEpk5ggf-jQrtTY2yQru0fW0WZB8hz19XywhFQ_mVMMahNHfycfXT2BMaV0wiBFKY8FXKqb5cErsCodcZ_STvqOTykWBaA4AWmJFRqd4i4enpf-MhgtkQK3"
+ jwkQ = "veD3yFnEOZegVIpIxPqIsj7zazjKRn-io1s3KJxkgaz5ND1o1JwbxiLuUNL9ufkj6cPOVCEHRkjQ2GabHnA0NYci4qRHBWdHhCD7aisS2D60xZAiAVmNZlEGLxRS7gFnyD8uneLILFFMalvJdIccCXzN3c8vPlC_9FlEzaEyDUmWzT_1zZES2GpaYeC73fNg7h-mJ6m-96Y6Wwvlx6YlCRCIPLU7l4kA-jca37T0IMNhobWmg8u4yqvVaqdDhojD"
+)
diff --git a/tests/e2e/testdata/mcp_route_authorization.yaml b/tests/e2e/testdata/mcp_route_authorization.yaml
new file mode 100644
index 0000000000..ba9565d4fb
--- /dev/null
+++ b/tests/e2e/testdata/mcp_route_authorization.yaml
@@ -0,0 +1,170 @@
+# Copyright Envoy AI Gateway Authors
+# SPDX-License-Identifier: Apache-2.0
+# The full text of the Apache license is available in the LICENSE file at
+# the root of the repo.
+
+apiVersion: gateway.networking.k8s.io/v1
+kind: GatewayClass
+metadata:
+ name: mcp-gateway-class-authorization
+spec:
+ controllerName: gateway.envoyproxy.io/gatewayclass-controller
+---
+apiVersion: gateway.networking.k8s.io/v1
+kind: Gateway
+metadata:
+ name: mcp-gateway-authorization
+ namespace: default
+spec:
+ gatewayClassName: mcp-gateway-class-authorization
+ listeners:
+ - name: http
+ protocol: HTTP
+ port: 80
+ infrastructure:
+ parametersRef:
+ group: gateway.envoyproxy.io
+ kind: EnvoyProxy
+ name: envoy-mcp-gateway-authorization
+---
+apiVersion: aigateway.envoyproxy.io/v1alpha1
+kind: MCPRoute
+metadata:
+ name: mcp-route-authorization-default-deny
+ namespace: default
+spec:
+ path: "/mcp-authorization"
+ parentRefs:
+ - name: mcp-gateway-authorization
+ kind: Gateway
+ group: gateway.networking.k8s.io
+ backendRefs:
+ - name: mcp-backend-authorization
+ port: 1063
+ securityPolicy:
+ oauth:
+ issuer: "https://auth-server.example.com"
+ jwks:
+ localJWKS:
+ type: ValueRef
+ valueRef:
+ group: ""
+ kind: ConfigMap
+ name: jwks-configmap
+ protectedResourceMetadata:
+ resource: "https://foo.bar.com/mcp"
+ resourceName: "example-resource"
+ scopesSupported:
+ - "echo"
+ - "sum"
+ - "countdown"
+ authorization:
+ rules:
+ - source:
+ jwtSource:
+ scopes:
+ - echo
+ target:
+ tools:
+ - backendName: mcp-backend-authorization
+ toolName: echo
+ arguments: args.text.matches("^Hello, .*!$")
+ - source:
+ jwtSource:
+ scopes:
+ - sum
+ target:
+ tools:
+ - backendName: mcp-backend-authorization
+ toolName: sum
+---
+# https://www.scottbrady.io/tools/jwt
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: jwks-configmap
+ namespace: default
+data:
+ jwks: |
+ {
+ "keys": [
+ {
+ "alg": "RS256",
+ "d": "CCv3lFeZmUautsntgGxmIqzOqTBrtUoWTC9zCvrm1YDCDYIwJgq1Xi5_P2tbWRSs_wIq90UWGIkVnNAv-uNTDiTyu8hvxqca1vqIDfpnfRwuOO-pGi6P3Z07XvXfg2tr-Bu0ALwJK-6EwB3hUO-CNZrXBJd_56LLr9qPhQ3e9KEVWu3gUfxzGV06HsZvYOFYxysR7MlTswiiwvR5FgE7YBS4izp80kPGV3QbbYCYlBYLGp52DZ1bWyCGo5ZSpPAt4Az9wdDTzJoTtflLymg8kZ-idQqk2_re214xQgeCuVAHujjC4r3GqSzbQGUqXicd-rbRLenyB22Ul8wyHqY8WtcFrGmHojK8b-W3M9m0-xYkMXmWcllYQuQ0LMP9K8Tl0uMpKsyd0AePItaWa_ft3dAzoBiUZA15X2_Nbbc9WbkmjN0Et8E1RWlrL5fzppbvLUl4mlSKHsLnwgmLx2OROjEnQsfzjMGxV2KhMZXzdvbRPTkaDtq3YT70ZiRIyvRD",
+ "dp": "w2Z3NznPTe6B_Yse51UfEiXZlxJblgTNIwZeupjHZPwns80pK7L1i6ID6NeCZHPXLslZyjjHeXUutCSSSPZBe9bYdzXC4LTIPutz8u7pCMTbqZkcr_LnKLv9PSqrNjRWfhC7i94KEVoFecAmUt2hG3RoU2xwjtdFBCxykzYwxwP0USvxEXUfDIUlMXVlXfJzEkm6KxLgz5KDf2N26k8Z4l6Cdb4b8qxc-oSVIvF1pYHxSrGwuoVpR4e-Lw8uOOgv",
+ "dq": "Czchi5z5wSkamEO-vpvJvTWIrTmigP2C_sEhhe2O5jXwVkyWR5Cc91wS1YVQ5U4499LP-holUtp0M4QD_41DGDJONjLb2w7Zo41LLF808r7pcI3t5ESE3JlGkztRFqvQlHxe5YaCqlN2_wVC5fYhCtJrWoGlWbntTKKFNNwjl7NUC_WOMEE0asIFaqiakCaTAB2wc8FL_Va9NamDPgKrR1jJo0RVK8M04pKkrLgEf3bq6mFPX5OF67qwlWchzu1_",
+ "e": "AQAB",
+ "key_ops": [
+ "sign"
+ ],
+ "kty": "RSA",
+ "n": "lQiREO6mH0tV14KjhSz02zNaJ1SExS1QjlGoMSbG2-NUeURmvnwg-eY95sDCFpWuH3_ovFyRBGTi0e3j79mrQhM53PZQys-gr-rYGz8LeHp8G6H3XmeDxTvyemhB6uiN4EZkjOo6xT2ipmPEN3u315xPCR60Pwac2E0t4vZGxtU4LGYatIFOYtUvDdMPBLfGMKVapHBzbx9Ll4INEic1fNrAIUVtOn6i3sxzGHj4iGWsMrEUIXDOWEHzioXgPuRjJDRjhHRuEeA9i_Y-a9hY92q6P-dcPnCDLNF3349WDyw7jIMlU6TLM8lQ5ci_TS_0avovXPNECsuOObtT78LJJKLg58ghTnqrihwvSccVgW4M43Ntv7TOAgOsRl-NKY7QQJIbkxemvh14-gzwA6LijMvb0Tjrh6NynKfCIO0ASsMp3K3uks4cYhBALLJ1E41V-cYqdwg0c6Jam0Y4OXxNv_0FfmcsOk8iXdroNgWjBs3KaObMiMvNOKHNWZ4PsEll",
+ "p": "yO5hho-83vQQ3t7HeVeinZClemDazWT5T7f2ZVMigcuyUNQjC69tyMzJ3I_UN5nUCwpKCw5wY8uCeT82o1j-OJC3irxWjAPHkkbsYTNxRnk8ShJ2UFdu5a7MEF82-QuRKciAv11cebEpk5ggf-jQrtTY2yQru0fW0WZB8hz19XywhFQ_mVMMahNHfycfXT2BMaV0wiBFKY8FXKqb5cErsCodcZ_STvqOTykWBaA4AWmJFRqd4i4enpf-MhgtkQK3",
+ "q": "veD3yFnEOZegVIpIxPqIsj7zazjKRn-io1s3KJxkgaz5ND1o1JwbxiLuUNL9ufkj6cPOVCEHRkjQ2GabHnA0NYci4qRHBWdHhCD7aisS2D60xZAiAVmNZlEGLxRS7gFnyD8uneLILFFMalvJdIccCXzN3c8vPlC_9FlEzaEyDUmWzT_1zZES2GpaYeC73fNg7h-mJ6m-96Y6Wwvlx6YlCRCIPLU7l4kA-jca37T0IMNhobWmg8u4yqvVaqdDhojD",
+ "qi": "TDrZ2CE6uHay64K4f9sSDN0QANTxk60SDVALWCz8apB66q5g2XuaTak2LvIAMBw758rIM_DhpCH7gs9sEc1UoFbs6KLR-6cb1WtsoTxNtXbXFQDuVtDRbjGjVWXRuRe8rY5Hca2Kx2-Tl1iXT4tBkMPcr-1TGHY8A5uiRzldY5u2Qu2W-wlGNwLe87CSzoo3QT0l1h9aMCNH6T6q1cD1ZiUktPWAmGtZl88YzC6dHrxIoYAz4BSVIdbGoV82vvuI",
+ "use": "sig",
+ "kid": "8b267675394d7786f98ae29d8fddf905"
+ },
+ {
+ "alg": "RS256",
+ "e": "AQAB",
+ "key_ops": [
+ "verify"
+ ],
+ "kty": "RSA",
+ "n": "lQiREO6mH0tV14KjhSz02zNaJ1SExS1QjlGoMSbG2-NUeURmvnwg-eY95sDCFpWuH3_ovFyRBGTi0e3j79mrQhM53PZQys-gr-rYGz8LeHp8G6H3XmeDxTvyemhB6uiN4EZkjOo6xT2ipmPEN3u315xPCR60Pwac2E0t4vZGxtU4LGYatIFOYtUvDdMPBLfGMKVapHBzbx9Ll4INEic1fNrAIUVtOn6i3sxzGHj4iGWsMrEUIXDOWEHzioXgPuRjJDRjhHRuEeA9i_Y-a9hY92q6P-dcPnCDLNF3349WDyw7jIMlU6TLM8lQ5ci_TS_0avovXPNECsuOObtT78LJJKLg58ghTnqrihwvSccVgW4M43Ntv7TOAgOsRl-NKY7QQJIbkxemvh14-gzwA6LijMvb0Tjrh6NynKfCIO0ASsMp3K3uks4cYhBALLJ1E41V-cYqdwg0c6Jam0Y4OXxNv_0FfmcsOk8iXdroNgWjBs3KaObMiMvNOKHNWZ4PsEll",
+ "use": "sig",
+ "kid": "8b267675394d7786f98ae29d8fddf905"
+ }
+ ]
+ }
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: mcp-backend-authorization
+ namespace: default
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: mcp-backend-authorization
+ template:
+ metadata:
+ labels:
+ app: mcp-backend-authorization
+ spec:
+ containers:
+ - name: mcp-backend-authorization
+ image: docker.io/envoyproxy/ai-gateway-testmcpserver:latest
+ imagePullPolicy: IfNotPresent
+ ports:
+ - containerPort: 1063
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: mcp-backend-authorization
+ namespace: default
+spec:
+ selector:
+ app: mcp-backend-authorization
+ ports:
+ - protocol: TCP
+ port: 1063
+ targetPort: 1063
+ type: ClusterIP
+---
+apiVersion: gateway.envoyproxy.io/v1alpha1
+kind: EnvoyProxy
+metadata:
+ name: envoy-mcp-gateway-authorization
+ namespace: default
+spec:
+ provider:
+ type: Kubernetes
+ kubernetes:
+ envoyDeployment:
+ container:
+ # Clear the default memory/cpu requirements for local tests.
+ resources: {}