From fe8b5715fd0b210e24031a0203e3e68750e7eafc Mon Sep 17 00:00:00 2001 From: Huabing Zhao Date: Tue, 4 Nov 2025 15:13:59 +0800 Subject: [PATCH 01/21] api for mcp authorization Signed-off-by: Huabing Zhao --- api/v1alpha1/mcp_route.go | 42 +++++++++++++ api/v1alpha1/zz_generated.deepcopy.go | 57 ++++++++++++++++++ .../aigateway.envoyproxy.io_mcproutes.yaml | 52 ++++++++++++++++ site/docs/api/api.mdx | 60 +++++++++++++++++++ 4 files changed, 211 insertions(+) diff --git a/api/v1alpha1/mcp_route.go b/api/v1alpha1/mcp_route.go index 5c037f89e..ec6f5d022 100644 --- a/api/v1alpha1/mcp_route.go +++ b/api/v1alpha1/mcp_route.go @@ -192,6 +192,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 +232,43 @@ 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. + // These rules are evaluated in order, the first matching rule will be applied, + // and the rest will be skipped. + // + // +optional + Rules []MCPRouteAuthorizationRule `json:"rules,omitempty"` + + // DefaultAction defines the default action to be taken if no rules match. + // If not specified, the default action is Deny. + // +optional + DefaultAction *egv1a1.AuthorizationAction `json:"defaultAction"` +} + +// 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 { + // Tools defines the list of tool names this rule applies to. The name must be a fully qualified tool name including the backend name. + // For example, "mcp-backend-name__tool-name". + // + // If a request calls a tool in this list, this rule is considered a match. + // If this request has a valid JWT token that contains all the required scopes defined in this rule, + // the request will be allowed. If not, the request will be denied. + // + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=16 + Tools []string `json:"tools"` + + // 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:MinItems=1 + // +kubebuilder:validation:MaxItems=16 + Scopes []egv1a1.JWTScope `json:"scopes"` +} + // 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 d2d42ae93..aabc3bf35 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -1012,6 +1012,58 @@ 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]) + } + } + if in.DefaultAction != nil { + in, out := &in.DefaultAction, &out.DefaultAction + *out = new(apiv1alpha1.AuthorizationAction) + **out = **in + } +} + +// 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 + if in.Tools != nil { + in, out := &in.Tools, &out.Tools + *out = make([]string, len(*in)) + copy(*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 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 +1171,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. 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 1734e611b..4a599e388 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,58 @@ spec: - credentialRefs - extractFrom type: object + authorization: + description: Authorization defines the configuration for the MCP + spec compatible authorization. + properties: + defaultAction: + description: |- + DefaultAction defines the default action to be taken if no rules match. + If not specified, the default action is Deny. + enum: + - Allow + - Deny + type: string + rules: + description: |- + Rules defines a list of authorization rules. + These rules are evaluated in order, the first matching rule will be applied, + and the rest will be skipped. + 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: + 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 + tools: + description: |- + Tools defines the list of tool names this rule applies to. The name must be a fully qualified tool name including the backend name. + For example, "mcp-backend-name__tool-name". + + If a request calls a tool in this list, this rule is considered a match. + If this request has a valid JWT token that contains all the required scopes defined in this rule, + the request will be allowed. If not, the request will be denied. + items: + type: string + maxItems: 16 + minItems: 1 + type: array + required: + - scopes + - tools + type: object + type: array + type: object extAuth: description: ExtAuth defines the configuration for External Authorization. properties: diff --git a/site/docs/api/api.mdx b/site/docs/api/api.mdx index 66fb5147c..ff81d14cf 100644 --- a/site/docs/api/api.mdx +++ b/site/docs/api/api.mdx @@ -408,6 +408,8 @@ MCPRouteList contains a list of MCPRoute. - [LLMRequestCostType](#llmrequestcosttype) - [MCPBackendAPIKey](#mcpbackendapikey) - [MCPBackendSecurityPolicy](#mcpbackendsecuritypolicy) +- [MCPRouteAuthorization](#mcprouteauthorization) +- [MCPRouteAuthorizationRule](#mcprouteauthorizationrule) - [MCPRouteBackendRef](#mcproutebackendref) - [MCPRouteOAuth](#mcprouteoauth) - [MCPRouteSecurityPolicy](#mcproutesecuritypolicy) @@ -1565,6 +1567,59 @@ 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 +1743,11 @@ MCPRouteSecurityPolicy defines the security policy for a MCPRoute. type="[ExtAuth](#extauth)" required="false" description="ExtAuth defines the configuration for External Authorization." +/> From a2424971046de0e0d7c3f3506438c7c18d61a582 Mon Sep 17 00:00:00 2001 From: Huabing Zhao Date: Wed, 26 Nov 2025 17:18:40 +0800 Subject: [PATCH 02/21] update API Signed-off-by: Huabing Zhao --- api/v1alpha1/mcp_route.go | 57 ++++++++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/api/v1alpha1/mcp_route.go b/api/v1alpha1/mcp_route.go index ec6f5d022..cd59ec304 100644 --- a/api/v1alpha1/mcp_route.go +++ b/api/v1alpha1/mcp_route.go @@ -250,23 +250,68 @@ type MCPRouteAuthorization struct { // 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 { - // Tools defines the list of tool names this rule applies to. The name must be a fully qualified tool name including the backend name. - // For example, "mcp-backend-name__tool-name". + // Source defines the authorization source for this rule. + // + // +kubebuilder:validation:Required + Source MCPAuthorizationSource `json:"source"` + + // Target defines the authorization target for this rule. // - // If a request calls a tool in this list, this rule is considered a match. - // If this request has a valid JWT token that contains all the required scopes defined in this rule, - // the request will be allowed. If not, the request will be denied. + // +kubebuilder:validation:Required + Target MCPAuthorizationTarget `json:"target"` +} + +type MCPAuthorizationTarget struct { + // Tools defines the list of tools this rule applies to. // // +kubebuilder:validation:MinItems=1 // +kubebuilder:validation:MaxItems=16 - Tools []string `json:"tools"` + Tools []ToolCall `json:"tools"` + // TODO: we can add resources, prompts, etc. in the future. +} + +type MCPAuthorizationSource struct { + // JWTSource defines the JWT scopes required for this rule to match. + // + // +kubebuilder:validation:Optional + 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. // // +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. +} + +type ToolCall struct { + // Tools defines the list of tool names this rule applies to. The name must be a fully qualified tool name including the backend name. + // For example, "mcp-backend-name__tool-name". + Name string `json:"name"` + + // Arguments defines the arguments that must be present in the tool call for this rule to match. + // + // +optional + Arguments map[string]string `json:"arguments,omitempty"` +} + +type ToolArgument struct { + // Name is the name of the argument. + Name string `json:"name"` + + // Value is the value of the argument. + Value ArgumentValues `json:"value"` +} + +type ArgumentValues struct { + Include []string `json:"include,omitempty"` + + IncludeRegex []string `json:"includeRegex,omitempty"` } // JWKS defines how to obtain JSON Web Key Sets (JWKS) either from a remote HTTP/HTTPS endpoint or from a local source. From 76f8729626a0491e3a1f5ed07fe7a9562e877d21 Mon Sep 17 00:00:00 2001 From: Huabing Zhao Date: Fri, 28 Nov 2025 17:25:50 +0800 Subject: [PATCH 03/21] authorization impl draft Signed-off-by: Huabing Zhao --- api/v1alpha1/mcp_route.go | 22 +- api/v1alpha1/zz_generated.deepcopy.go | 87 +++++- go.mod | 3 +- go.sum | 2 - internal/filterapi/mcpconfig.go | 62 +++++ internal/mcpproxy/authorization.go | 124 +++++++++ internal/mcpproxy/authorization_test.go | 252 ++++++++++++++++++ internal/mcpproxy/handlers.go | 14 +- internal/mcpproxy/handlers_test.go | 4 +- internal/mcpproxy/mcpproxy.go | 2 + .../aigateway.envoyproxy.io_mcproutes.yaml | 77 ++++-- site/docs/api/api.mdx | 105 +++++++- 12 files changed, 694 insertions(+), 60 deletions(-) create mode 100644 internal/mcpproxy/authorization.go create mode 100644 internal/mcpproxy/authorization_test.go diff --git a/api/v1alpha1/mcp_route.go b/api/v1alpha1/mcp_route.go index cd59ec304..b5056bd49 100644 --- a/api/v1alpha1/mcp_route.go +++ b/api/v1alpha1/mcp_route.go @@ -267,8 +267,6 @@ type MCPAuthorizationTarget struct { // +kubebuilder:validation:MinItems=1 // +kubebuilder:validation:MaxItems=16 Tools []ToolCall `json:"tools"` - - // TODO: we can add resources, prompts, etc. in the future. } type MCPAuthorizationSource struct { @@ -286,21 +284,27 @@ type JWTSource struct { // +kubebuilder:validation:MaxItems=16 Scopes []egv1a1.JWTScope `json:"scopes"` - //TODO : we can add more fields in the future, e.g., audiences, claims, etc. + // TODO : we can add more fields in the future, e.g., audiences, claims, etc. } type ToolCall struct { - // Tools defines the list of tool names this rule applies to. The name must be a fully qualified tool name including the backend name. - // For example, "mcp-backend-name__tool-name". - Name string `json:"name"` + // 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 defines the arguments that must be present in the tool call for this rule to match. // // +optional - Arguments map[string]string `json:"arguments,omitempty"` + // Arguments map[string]string `json:"arguments,omitempty"` } -type ToolArgument struct { +/*type ToolArgument struct { // Name is the name of the argument. Name string `json:"name"` @@ -312,7 +316,7 @@ type ArgumentValues struct { Include []string `json:"include,omitempty"` IncludeRegex []string `json:"includeRegex,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." diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index aabc3bf35..8cc6c834c 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,46 @@ 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 + if in.JWTSource != nil { + in, out := &in.JWTSource, &out.JWTSource + *out = new(JWTSource) + (*in).DeepCopyInto(*out) + } +} + +// 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)) + copy(*out, *in) + } +} + +// 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 @@ -1042,16 +1102,8 @@ func (in *MCPRouteAuthorization) DeepCopy() *MCPRouteAuthorization { // 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 - if in.Tools != nil { - in, out := &in.Tools, &out.Tools - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.Scopes != nil { - in, out := &in.Scopes, &out.Scopes - *out = make([]apiv1alpha1.JWTScope, len(*in)) - copy(*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. @@ -1321,6 +1373,21 @@ 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 +} + +// 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 8f0f9b7e8..efa4bb04c 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 d650f618c..1262163e3 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/filterapi/mcpconfig.go b/internal/filterapi/mcpconfig.go index 852831f59..5f41cdb37 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,62 @@ 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. + // These rules are evaluated in order, the first matching rule will be applied, + // and the rest will be skipped. + Rules []MCPRouteAuthorizationRule `json:"rules,omitempty"` + + // DefaultAction defines the action to take when no rules match. + // If unset, the default is Deny. + DefaultAction *AuthorizationAction `json:"defaultAction,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"` + + // Action defines whether to allow or deny requests that match this rule. + Action AuthorizationAction `json:"action"` +} + +// AuthorizationAction represents an authorization decision. +type AuthorizationAction string + +const ( + // AuthorizationActionAllow allows the request. + AuthorizationActionAllow AuthorizationAction = "Allow" + // AuthorizationActionDeny denies the request. + AuthorizationActionDeny AuthorizationAction = "Deny" +) + +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"` +} diff --git a/internal/mcpproxy/authorization.go b/internal/mcpproxy/authorization.go new file mode 100644 index 000000000..db957a502 --- /dev/null +++ b/internal/mcpproxy/authorization.go @@ -0,0 +1,124 @@ +// 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" + "log/slog" + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v5" + "k8s.io/utils/ptr" + + "github.com/envoyproxy/ai-gateway/internal/filterapi" +) + +func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorization, headers http.Header, backendName, toolName string) bool { + defaultAction := ptr.Deref(authorization.DefaultAction, filterapi.AuthorizationActionDeny) == filterapi.AuthorizationActionAllow + + // If there are no rules, return the default action. + if len(authorization.Rules) == 0 { + return defaultAction + } + + // If the rules are defined, a valid bearer token is required. + token, err := bearerToken(headers.Get("Authorization")) + if err != nil { + m.l.Info("missing or invalid bearer token", slog.String("error", err.Error())) + return false + } + + claims := jwt.MapClaims{} + // JWT verification is performed by Envoy before reaching here. So we only need to parse the token without verification. + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) + if _, _, err := parser.ParseUnverified(token, claims); err != nil { + m.l.Info("failed to parse JWT token", slog.String("error", err.Error())) + return false + } + + scopeSet := make(map[string]struct{}) + for _, scope := range extractScopes(claims) { + scopeSet[scope] = struct{}{} + } + + target := filterapi.ToolCall{BackendName: backendName, ToolName: toolName} + for _, rule := range authorization.Rules { + if !toolTargetMatches(target, rule.Target.Tools) { + continue + } + if scopesSatisfied(scopeSet, rule.Source.JWTSource.Scopes) { + return rule.Action == filterapi.AuthorizationActionAllow + } + } + + return defaultAction +} + +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 toolTargetMatches(target filterapi.ToolCall, tools []filterapi.ToolCall) bool { + if len(tools) == 0 { + return true + } + for _, t := range tools { + if t.BackendName == target.BackendName && t.ToolName == target.ToolName { + return true + } + } + return false +} + +func scopesSatisfied(have map[string]struct{}, required []string) bool { + if len(required) == 0 { + return true + } + for _, scope := range required { + if _, ok := have[scope]; !ok { + return false + } + } + return true +} diff --git a/internal/mcpproxy/authorization_test.go b/internal/mcpproxy/authorization_test.go new file mode 100644 index 000000000..a232a923b --- /dev/null +++ b/internal/mcpproxy/authorization_test.go @@ -0,0 +1,252 @@ +// 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" + "testing" + + "github.com/golang-jwt/jwt/v5" + "k8s.io/utils/ptr" + + "github.com/envoyproxy/ai-gateway/internal/filterapi" +) + +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 + expectAllowed bool + }{ + { + name: "matching tool and scope allowed", + auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: ptr.To(filterapi.AuthorizationActionDeny), + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + }, + Action: filterapi.AuthorizationActionAllow, + }, + }, + }, + header: "Bearer " + makeToken("read", "write"), + backendName: "backend1", + toolName: "tool1", + expectAllowed: true, + }, + { + name: "no matching rule falls back to default deny - tool mismatch", + auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: ptr.To(filterapi.AuthorizationActionDeny), + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + }, + Action: filterapi.AuthorizationActionAllow, + }, + }, + }, + header: "Bearer " + makeToken("read", "write"), + backendName: "backend1", + toolName: "other-tool", + expectAllowed: false, + }, + { + name: "no matching rule falls back to default deny - scope mismatch", + auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: ptr.To(filterapi.AuthorizationActionDeny), + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + }, + Action: filterapi.AuthorizationActionAllow, + }, + }, + }, + header: "Bearer " + makeToken("foo", "bar"), + backendName: "backend1", + toolName: "other-tool", + expectAllowed: false, + }, + { + name: "matching tool and scope denied", + auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: ptr.To(filterapi.AuthorizationActionAllow), + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"delete"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + }, + Action: filterapi.AuthorizationActionDeny, + }, + }, + }, + header: "Bearer " + makeToken("delete"), + backendName: "backend1", + toolName: "tool1", + expectAllowed: false, + }, + { + name: "no matching rule falls back to default allow - tool mismatch", + auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: ptr.To(filterapi.AuthorizationActionAllow), + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + }, + Action: filterapi.AuthorizationActionDeny, + }, + }, + }, + header: "Bearer " + makeToken("read", "write"), + backendName: "backend1", + toolName: "other-tool", + expectAllowed: true, + }, + { + name: "no matching rule falls back to default allow - scope mismatch", + auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: ptr.To(filterapi.AuthorizationActionAllow), + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + }, + Action: filterapi.AuthorizationActionDeny, + }, + }, + }, + header: "Bearer " + makeToken("foo", "bar"), + backendName: "backend1", + toolName: "other-tool", + expectAllowed: true, + }, + { + name: "no rules falls back to default allow", + auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: ptr.To(filterapi.AuthorizationActionAllow), + }, + header: "", + backendName: "backend1", + toolName: "tool1", + expectAllowed: true, + }, + { + name: "multiple rules, first match applied - denied", + auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: ptr.To(filterapi.AuthorizationActionDeny), + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + }, + Action: filterapi.AuthorizationActionDeny, + }, + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + }, + Action: filterapi.AuthorizationActionAllow, + }, + }, + }, + header: "Bearer " + makeToken("read", "write"), + backendName: "backend1", + toolName: "tool1", + expectAllowed: false, + }, + { + name: "multiple rules, first match applied - allowed", + auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: ptr.To(filterapi.AuthorizationActionDeny), + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + }, + Action: filterapi.AuthorizationActionAllow, + }, + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + }, + Action: filterapi.AuthorizationActionDeny, + }, + }, + }, + header: "Bearer " + makeToken("read", "write"), + backendName: "backend1", + toolName: "tool1", + expectAllowed: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + headers := http.Header{} + if tt.header != "" { + headers.Set("Authorization", tt.header) + } + allowed := proxy.authorizeRequest(tt.auth, headers, tt.backendName, tt.toolName) + if allowed != tt.expectAllowed { + t.Fatalf("expected %v, got %v", tt.expectAllowed, allowed) + } + }) + } +} diff --git a/internal/mcpproxy/handlers.go b/internal/mcpproxy/handlers.go index 34504cf1a..6816b847d 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,14 @@ 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 { + if !m.authorizeRequest(route.authorization, headers, backendName, toolName) { + onErrorResponse(w, http.StatusUnauthorized, "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 f7f362dce..7515cfaad 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, nil) 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, nil) require.Error(t, err) require.Equal(t, http.StatusInternalServerError, rr.Code) diff --git a/internal/mcpproxy/mcpproxy.go b/internal/mcpproxy/mcpproxy.go index 818d3918e..688fb3dab 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 *filterapi.MCPRouteAuthorization } // toolSelector filters tools using include patterns with exact matches or regular expressions. @@ -135,6 +136,7 @@ func (p *ProxyConfig) LoadConfig(_ context.Context, config *filterapi.Config) er r := &mcpProxyConfigRoute{ backends: make(map[filterapi.MCPBackendName]filterapi.MCPBackend, len(route.Backends)), toolSelectors: make(map[filterapi.MCPBackendName]*toolSelector, len(route.Backends)), + authorization: route.Authorization, } 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 4a599e388..d16388bf5 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 @@ -603,33 +603,58 @@ spec: 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: - 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 - tools: - description: |- - Tools defines the list of tool names this rule applies to. The name must be a fully qualified tool name including the backend name. - For example, "mcp-backend-name__tool-name". - - If a request calls a tool in this list, this rule is considered a match. - If this request has a valid JWT token that contains all the required scopes defined in this rule, - the request will be allowed. If not, the request will be denied. - items: - type: string - maxItems: 16 - minItems: 1 - type: array + 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 + 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: + properties: + 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: - - scopes - - tools + - source + - target type: object type: array type: object diff --git a/site/docs/api/api.mdx b/site/docs/api/api.mdx index ff81d14cf..6a79bea9f 100644 --- a/site/docs/api/api.mdx +++ b/site/docs/api/api.mdx @@ -404,8 +404,11 @@ 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) @@ -417,6 +420,7 @@ MCPRouteList contains a list of MCPRoute. - [MCPRouteStatus](#mcproutestatus) - [MCPToolFilter](#mcptoolfilter) - [ProtectedResourceMetadata](#protectedresourcemetadata) +- [ToolCall](#toolcall) - [VersionedAPISchema](#versionedapischema) ### Type Definitions @@ -1445,6 +1449,27 @@ JWKS defines how to obtain JSON Web Key Sets (JWKS) either from a remote HTTP/HT /> +#### JWTSource + + + +**Appears in:** +- [MCPAuthorizationSource](#mcpauthorizationsource) + + + +##### Fields + + + + + + #### LLMRequestCost @@ -1515,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) + + + +##### Fields + + + + + + +#### MCPAuthorizationTarget + + + +**Appears in:** +- [MCPRouteAuthorizationRule](#mcprouteauthorizationrule) + + + +##### Fields + + + + + + #### MCPBackendAPIKey @@ -1608,15 +1675,15 @@ Reference: https://modelcontextprotocol.io/specification/draft/basic/authorizati @@ -1890,6 +1957,32 @@ References: /> +#### ToolCall + + + +**Appears in:** +- [MCPAuthorizationTarget](#mcpauthorizationtarget) + + + +##### Fields + + + + + + #### VersionedAPISchema From f3196ca6b5d4cb268846e1784cf68c7ce44e0216 Mon Sep 17 00:00:00 2001 From: Huabing Zhao Date: Fri, 28 Nov 2025 18:34:27 +0800 Subject: [PATCH 04/21] update api Signed-off-by: Huabing Zhao --- api/v1alpha1/mcp_route.go | 7 +++++++ .../templates/aigateway.envoyproxy.io_mcproutes.yaml | 8 ++++++++ site/docs/api/api.mdx | 5 +++++ 3 files changed, 20 insertions(+) diff --git a/api/v1alpha1/mcp_route.go b/api/v1alpha1/mcp_route.go index b5056bd49..b51525090 100644 --- a/api/v1alpha1/mcp_route.go +++ b/api/v1alpha1/mcp_route.go @@ -259,11 +259,17 @@ type MCPRouteAuthorizationRule struct { // // +kubebuilder:validation:Required Target MCPAuthorizationTarget `json:"target"` + + // Action defines whether to allow or deny requests that match this rule. + // + // +kubebuilder:validation:Required + Action egv1a1.AuthorizationAction `json:"action"` } 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"` @@ -280,6 +286,7 @@ 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"` 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 d16388bf5..930c53aa7 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 @@ -603,6 +603,13 @@ spec: 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: + action: + description: Action defines whether to allow or deny + requests that match this rule. + enum: + - Allow + - Deny + type: string source: description: Source defines the authorization source for this rule. @@ -653,6 +660,7 @@ spec: - tools type: object required: + - action - source - target type: object diff --git a/site/docs/api/api.mdx b/site/docs/api/api.mdx index 6a79bea9f..6ca19a07c 100644 --- a/site/docs/api/api.mdx +++ b/site/docs/api/api.mdx @@ -1684,6 +1684,11 @@ Reference: https://modelcontextprotocol.io/specification/draft/basic/authorizati type="[MCPAuthorizationTarget](#mcpauthorizationtarget)" required="true" description="Target defines the authorization target for this rule." +/> From bc62a6224d8922549188a669545515830b4e4f9b Mon Sep 17 00:00:00 2001 From: Huabing Zhao Date: Fri, 28 Nov 2025 18:40:44 +0800 Subject: [PATCH 05/21] update test Signed-off-by: Huabing Zhao --- internal/mcpproxy/authorization_test.go | 31 +++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/internal/mcpproxy/authorization_test.go b/internal/mcpproxy/authorization_test.go index a232a923b..db280ee2b 100644 --- a/internal/mcpproxy/authorization_test.go +++ b/internal/mcpproxy/authorization_test.go @@ -175,6 +175,37 @@ func TestAuthorizeRequest(t *testing.T) { toolName: "tool1", expectAllowed: true, }, + { + name: "no rules falls back to default deny", + auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: ptr.To(filterapi.AuthorizationActionDeny), + }, + header: "", + backendName: "backend1", + toolName: "tool1", + expectAllowed: false, + }, + { + name: "no bearer token not allowed when rules exist", + auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: ptr.To(filterapi.AuthorizationActionAllow), + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + }, + Action: filterapi.AuthorizationActionDeny, + }, + }, + }, + header: "", + backendName: "backend1", + toolName: "tool1", + expectAllowed: false, + }, { name: "multiple rules, first match applied - denied", auth: &filterapi.MCPRouteAuthorization{ From d8b87e6e068bc3fa9b38107339634553bf1f7815 Mon Sep 17 00:00:00 2001 From: Huabing Zhao Date: Mon, 1 Dec 2025 11:50:31 +0800 Subject: [PATCH 06/21] update Signed-off-by: Huabing Zhao --- internal/controller/gateway.go | 46 +++++++++++++++++++++++++ internal/filterapi/mcpconfig.go | 3 +- internal/mcpproxy/authorization.go | 4 +-- internal/mcpproxy/authorization_test.go | 46 ++++++++++++++++++------- 4 files changed, 82 insertions(+), 17 deletions(-) diff --git a/internal/controller/gateway.go b/internal/controller/gateway.go index b1aa5bcb5..068b53854 100644 --- a/internal/controller/gateway.go +++ b/internal/controller/gateway.go @@ -13,6 +13,7 @@ import ( "strings" "time" + egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1" "github.com/go-logr/logr" "github.com/google/uuid" appsv1 "k8s.io/api/apps/v1" @@ -471,6 +472,51 @@ 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{} + + defaultAction := ptr.Deref(authorization.DefaultAction, egv1a1.AuthorizationActionDeny) + if defaultAction == egv1a1.AuthorizationActionAllow { + mcpRoute.Authorization.DefaultAction = filterapi.AuthorizationActionAllow + } else { + mcpRoute.Authorization.DefaultAction = filterapi.AuthorizationActionDeny + } + + for _, rule := range authorization.Rules { + action := filterapi.AuthorizationActionDeny + if rule.Action == egv1a1.AuthorizationActionAllow { + action = filterapi.AuthorizationActionAllow + } + + 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, + } + } + + mcpRule := filterapi.MCPRouteAuthorizationRule{ + Action: action, + 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/filterapi/mcpconfig.go b/internal/filterapi/mcpconfig.go index 5f41cdb37..2c05465e2 100644 --- a/internal/filterapi/mcpconfig.go +++ b/internal/filterapi/mcpconfig.go @@ -70,8 +70,7 @@ type MCPRouteAuthorization struct { Rules []MCPRouteAuthorizationRule `json:"rules,omitempty"` // DefaultAction defines the action to take when no rules match. - // If unset, the default is Deny. - DefaultAction *AuthorizationAction `json:"defaultAction,omitempty"` + DefaultAction AuthorizationAction `json:"defaultAction,omitempty"` } // MCPRouteAuthorizationRule defines an authorization rule for MCPRoute based on the MCP authorization spec. diff --git a/internal/mcpproxy/authorization.go b/internal/mcpproxy/authorization.go index db957a502..d047c575a 100644 --- a/internal/mcpproxy/authorization.go +++ b/internal/mcpproxy/authorization.go @@ -12,13 +12,12 @@ import ( "strings" "github.com/golang-jwt/jwt/v5" - "k8s.io/utils/ptr" "github.com/envoyproxy/ai-gateway/internal/filterapi" ) func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorization, headers http.Header, backendName, toolName string) bool { - defaultAction := ptr.Deref(authorization.DefaultAction, filterapi.AuthorizationActionDeny) == filterapi.AuthorizationActionAllow + defaultAction := authorization.DefaultAction == filterapi.AuthorizationActionAllow // If there are no rules, return the default action. if len(authorization.Rules) == 0 { @@ -115,6 +114,7 @@ func scopesSatisfied(have map[string]struct{}, 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 diff --git a/internal/mcpproxy/authorization_test.go b/internal/mcpproxy/authorization_test.go index db280ee2b..975c9835b 100644 --- a/internal/mcpproxy/authorization_test.go +++ b/internal/mcpproxy/authorization_test.go @@ -12,7 +12,6 @@ import ( "testing" "github.com/golang-jwt/jwt/v5" - "k8s.io/utils/ptr" "github.com/envoyproxy/ai-gateway/internal/filterapi" ) @@ -42,11 +41,11 @@ func TestAuthorizeRequest(t *testing.T) { { name: "matching tool and scope allowed", auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: ptr.To(filterapi.AuthorizationActionDeny), + DefaultAction: filterapi.AuthorizationActionDeny, Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ - JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + JWTSource: filterapi.JWTSource{Scopes: []string{"read", "write"}}, }, Target: filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, @@ -60,10 +59,31 @@ func TestAuthorizeRequest(t *testing.T) { toolName: "tool1", expectAllowed: true, }, + { + name: "matching tool but insufficient scopes not allowed", + auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: filterapi.AuthorizationActionDeny, + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read", "write"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + }, + Action: filterapi.AuthorizationActionAllow, + }, + }, + }, + header: "Bearer " + makeToken("read"), + backendName: "backend1", + toolName: "tool1", + expectAllowed: false, + }, { name: "no matching rule falls back to default deny - tool mismatch", auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: ptr.To(filterapi.AuthorizationActionDeny), + DefaultAction: filterapi.AuthorizationActionDeny, Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ @@ -84,7 +104,7 @@ func TestAuthorizeRequest(t *testing.T) { { name: "no matching rule falls back to default deny - scope mismatch", auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: ptr.To(filterapi.AuthorizationActionDeny), + DefaultAction: filterapi.AuthorizationActionDeny, Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ @@ -105,7 +125,7 @@ func TestAuthorizeRequest(t *testing.T) { { name: "matching tool and scope denied", auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: ptr.To(filterapi.AuthorizationActionAllow), + DefaultAction: filterapi.AuthorizationActionAllow, Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ @@ -126,7 +146,7 @@ func TestAuthorizeRequest(t *testing.T) { { name: "no matching rule falls back to default allow - tool mismatch", auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: ptr.To(filterapi.AuthorizationActionAllow), + DefaultAction: filterapi.AuthorizationActionAllow, Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ @@ -147,7 +167,7 @@ func TestAuthorizeRequest(t *testing.T) { { name: "no matching rule falls back to default allow - scope mismatch", auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: ptr.To(filterapi.AuthorizationActionAllow), + DefaultAction: filterapi.AuthorizationActionAllow, Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ @@ -168,7 +188,7 @@ func TestAuthorizeRequest(t *testing.T) { { name: "no rules falls back to default allow", auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: ptr.To(filterapi.AuthorizationActionAllow), + DefaultAction: filterapi.AuthorizationActionAllow, }, header: "", backendName: "backend1", @@ -178,7 +198,7 @@ func TestAuthorizeRequest(t *testing.T) { { name: "no rules falls back to default deny", auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: ptr.To(filterapi.AuthorizationActionDeny), + DefaultAction: filterapi.AuthorizationActionDeny, }, header: "", backendName: "backend1", @@ -188,7 +208,7 @@ func TestAuthorizeRequest(t *testing.T) { { name: "no bearer token not allowed when rules exist", auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: ptr.To(filterapi.AuthorizationActionAllow), + DefaultAction: filterapi.AuthorizationActionAllow, Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ @@ -209,7 +229,7 @@ func TestAuthorizeRequest(t *testing.T) { { name: "multiple rules, first match applied - denied", auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: ptr.To(filterapi.AuthorizationActionDeny), + DefaultAction: filterapi.AuthorizationActionDeny, Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ @@ -239,7 +259,7 @@ func TestAuthorizeRequest(t *testing.T) { { name: "multiple rules, first match applied - allowed", auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: ptr.To(filterapi.AuthorizationActionDeny), + DefaultAction: filterapi.AuthorizationActionDeny, Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ From 1d2c9d2cf4ae955e13f6526c89a16c6dd02bb1ce Mon Sep 17 00:00:00 2001 From: Huabing Zhao Date: Mon, 1 Dec 2025 13:12:28 +0800 Subject: [PATCH 07/21] add e2e test Signed-off-by: Huabing Zhao --- api/v1alpha1/mcp_route.go | 2 + internal/mcpproxy/handlers_test.go | 4 +- .../aigateway.envoyproxy.io_mcproutes.yaml | 3 + tests/crdcel/main_test.go | 4 + .../authorization_without_oauth.yaml | 32 ++++ tests/e2e/mcp_route_authorization_test.go | 176 ++++++++++++++++++ .../e2e/testdata/mcp_route_authorization.yaml | 172 +++++++++++++++++ 7 files changed, 391 insertions(+), 2 deletions(-) create mode 100644 tests/crdcel/testdata/mcpgatewayroutes/authorization_without_oauth.yaml create mode 100644 tests/e2e/mcp_route_authorization_test.go create mode 100644 tests/e2e/testdata/mcp_route_authorization.yaml diff --git a/api/v1alpha1/mcp_route.go b/api/v1alpha1/mcp_route.go index b51525090..66c54d25d 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. // diff --git a/internal/mcpproxy/handlers_test.go b/internal/mcpproxy/handlers_test.go index 7515cfaad..956e7ea45 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, 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, 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/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 930c53aa7..46afc361c 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 @@ -4211,6 +4211,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/tests/crdcel/main_test.go b/tests/crdcel/main_test.go index ced9b3399..04e9fb995 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 000000000..a336feb7d --- /dev/null +++ b/tests/crdcel/testdata/mcpgatewayroutes/authorization_without_oauth.yaml @@ -0,0 +1,32 @@ +# 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: + defaultAction: Deny + rules: + - source: + jwtSource: + scopes: + - echo + target: + tools: + - backendName: mcp-service + toolName: echo + action: Allow diff --git a/tests/e2e/mcp_route_authorization_test.go b/tests/e2e/mcp_route_authorization_test.go new file mode 100644 index 000000000..856ff3048 --- /dev/null +++ b/tests/e2e/mcp_route_authorization_test.go @@ -0,0 +1,176 @@ +// 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 ( + "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, "echo", "sum") + authHTTPClient := &http.Client{ + Timeout: 10 * time.Second, + Transport: &bearerTokenTransport{ + token: token, + }, + } + testMCPRouteTools( + t.Context(), + t, + client, + fwd.Address(), + "/mcp-authorization", + testMCPServerAllToolNames("mcp-backend-authorization__"), + authHTTPClient, + true, + true, + ) + }) + + 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) + + var sess *mcp.ClientSession + require.Eventually(t, func() bool { + var err error + sess, err = client.Connect( + ctx, + &mcp.StreamableClientTransport{ + Endpoint: fmt.Sprintf("%s/mcp-authorization", fwd.Address()), + HTTPClient: authHTTPClient, + }, 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") + t.Cleanup(func() { + if sess != nil { + _ = 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, "401") || strings.Contains(errMsg, "authorization"), "unexpected error: %v", err) + }) +} + +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 000000000..e09c2d221 --- /dev/null +++ b/tests/e2e/testdata/mcp_route_authorization.yaml @@ -0,0 +1,172 @@ +# 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: + defaultAction: Deny + rules: + - source: + jwtSource: + scopes: + - echo + target: + tools: + - backendName: mcp-backend-authorization + toolName: echo + action: Allow + - source: + jwtSource: + scopes: + - sum + target: + tools: + - backendName: mcp-backend-authorization + toolName: sum + action: Allow +--- +# 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: {} From fcb29854c1ac110b0a943daf90cdaee50195563e Mon Sep 17 00:00:00 2001 From: Huabing Zhao Date: Mon, 1 Dec 2025 21:35:10 +0800 Subject: [PATCH 08/21] authorization against tool arguments Signed-off-by: Huabing Zhao --- api/v1alpha1/mcp_route.go | 18 +-- api/v1alpha1/zz_generated.deepcopy.go | 11 +- internal/controller/gateway.go | 1 + internal/filterapi/mcpconfig.go | 4 + internal/mcpproxy/authorization.go | 56 ++++++- internal/mcpproxy/authorization_test.go | 153 +++++++++++++++++- internal/mcpproxy/handlers.go | 2 +- .../aigateway.envoyproxy.io_mcproutes.yaml | 8 + site/docs/api/api.mdx | 5 + tests/e2e/mcp_route_authorization_test.go | 128 +++++++++++---- .../e2e/testdata/mcp_route_authorization.yaml | 2 + 11 files changed, 335 insertions(+), 53 deletions(-) diff --git a/api/v1alpha1/mcp_route.go b/api/v1alpha1/mcp_route.go index 66c54d25d..c75ef15b5 100644 --- a/api/v1alpha1/mcp_route.go +++ b/api/v1alpha1/mcp_route.go @@ -308,25 +308,13 @@ type ToolCall struct { ToolName string `json:"toolName"` // Arguments defines the arguments that must be present in the tool call for this rule to match. + // Keys must exist and their values must match the provided RE2-compatible regular expressions. + // If the argument is a non-string type, it will be matched against its JSON representation. // // +optional - // Arguments map[string]string `json:"arguments,omitempty"` + Arguments map[string]string `json:"arguments,omitempty"` } -/*type ToolArgument struct { - // Name is the name of the argument. - Name string `json:"name"` - - // Value is the value of the argument. - Value ArgumentValues `json:"value"` -} - -type ArgumentValues struct { - Include []string `json:"include,omitempty"` - - IncludeRegex []string `json:"includeRegex,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 8cc6c834c..ea363c652 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -981,7 +981,9 @@ func (in *MCPAuthorizationTarget) DeepCopyInto(out *MCPAuthorizationTarget) { if in.Tools != nil { in, out := &in.Tools, &out.Tools *out = make([]ToolCall, len(*in)) - copy(*out, *in) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } } @@ -1376,6 +1378,13 @@ func (in *ProtectedResourceMetadata) DeepCopy() *ProtectedResourceMetadata { // 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 = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToolCall. diff --git a/internal/controller/gateway.go b/internal/controller/gateway.go index 068b53854..ff0d26660 100644 --- a/internal/controller/gateway.go +++ b/internal/controller/gateway.go @@ -500,6 +500,7 @@ func mcpConfig(mcpRoutes []aigv1a1.MCPRoute) *filterapi.MCPConfig { tools[i] = filterapi.ToolCall{ BackendName: tool.BackendName, ToolName: tool.ToolName, + Arguments: tool.Arguments, } } diff --git a/internal/filterapi/mcpconfig.go b/internal/filterapi/mcpconfig.go index 2c05465e2..f3f686b0b 100644 --- a/internal/filterapi/mcpconfig.go +++ b/internal/filterapi/mcpconfig.go @@ -118,4 +118,8 @@ type ToolCall struct { // ToolName is the name of the tool. ToolName string `json:"toolName"` + + // Arguments defines required arguments (exact key names with regex patterns for values). + // All patterns must match for the rule to apply. + Arguments map[string]string `json:"arguments,omitempty"` } diff --git a/internal/mcpproxy/authorization.go b/internal/mcpproxy/authorization.go index d047c575a..e21e41bf0 100644 --- a/internal/mcpproxy/authorization.go +++ b/internal/mcpproxy/authorization.go @@ -6,9 +6,11 @@ package mcpproxy import ( + "encoding/json" "errors" "log/slog" "net/http" + "regexp" "strings" "github.com/golang-jwt/jwt/v5" @@ -16,7 +18,7 @@ import ( "github.com/envoyproxy/ai-gateway/internal/filterapi" ) -func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorization, headers http.Header, backendName, toolName string) bool { +func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorization, headers http.Header, backendName, toolName string, argments any) bool { defaultAction := authorization.DefaultAction == filterapi.AuthorizationActionAllow // If there are no rules, return the default action. @@ -44,9 +46,14 @@ func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorizati scopeSet[scope] = struct{}{} } - target := filterapi.ToolCall{BackendName: backendName, ToolName: toolName} for _, rule := range authorization.Rules { - if !toolTargetMatches(target, rule.Target.Tools) { + var args map[string]any + if argments != nil { + if cast, ok := argments.(map[string]any); ok { + args = cast + } + } + if !toolMatches(args, filterapi.ToolCall{BackendName: backendName, ToolName: toolName}, rule.Target.Tools) { continue } if scopesSatisfied(scopeSet, rule.Source.JWTSource.Scopes) { @@ -98,15 +105,54 @@ func extractScopes(claims jwt.MapClaims) []string { } } -func toolTargetMatches(target filterapi.ToolCall, tools []filterapi.ToolCall) bool { +func toolMatches(args map[string]any, target filterapi.ToolCall, tools []filterapi.ToolCall) bool { if len(tools) == 0 { return true } + for _, t := range tools { - if t.BackendName == target.BackendName && t.ToolName == target.ToolName { + if t.BackendName != target.BackendName || t.ToolName != target.ToolName { + continue + } + if len(t.Arguments) == 0 { + return true + } + if args == nil { + return false + } + allMatch := true + for key, pattern := range t.Arguments { + rawVal, ok := args[key] + if !ok { + allMatch = false + break + } + re, err := regexp.Compile(pattern) + if err != nil { + allMatch = false + break + } + var data []byte + if s, ok := rawVal.(string); ok { + data = []byte(s) + } else { + jsonVal, err := json.Marshal(rawVal) + if err != nil { + allMatch = false + break + } + data = jsonVal + } + if !re.Match(data) { + allMatch = false + break + } + } + if allMatch { return true } } + // If no matching tool entry or no arguments matched, fail. return false } diff --git a/internal/mcpproxy/authorization_test.go b/internal/mcpproxy/authorization_test.go index 975c9835b..0b78010d9 100644 --- a/internal/mcpproxy/authorization_test.go +++ b/internal/mcpproxy/authorization_test.go @@ -36,6 +36,7 @@ func TestAuthorizeRequest(t *testing.T) { header string backendName string toolName string + args map[string]any expectAllowed bool }{ { @@ -59,6 +60,156 @@ func TestAuthorizeRequest(t *testing.T) { toolName: "tool1", expectAllowed: true, }, + { + name: "matching tool scope and arguments regex allowed", + auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: filterapi.AuthorizationActionDeny, + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{ + BackendName: "backend1", + ToolName: "tool1", + Arguments: map[string]string{ + "mode": "fast|slow", + "user": "u-[0-9]+", + "debug": "true", + }, + }}, + }, + Action: filterapi.AuthorizationActionAllow, + }, + }, + }, + header: "Bearer " + makeToken("read"), + backendName: "backend1", + toolName: "tool1", + args: map[string]any{ + "mode": "fast", + "user": "u-123", + "debug": "true", + }, + expectAllowed: true, + }, + { + name: "argument regex mismatch denied", + auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: filterapi.AuthorizationActionDeny, + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{ + BackendName: "backend1", + ToolName: "tool1", + Arguments: map[string]string{ + "mode": "fast|slow", + }, + }}, + }, + Action: filterapi.AuthorizationActionAllow, + }, + }, + }, + header: "Bearer " + makeToken("read"), + backendName: "backend1", + toolName: "tool1", + args: map[string]any{ + "mode": "other", + }, + expectAllowed: false, + }, + { + name: "missing argument denies when required", + auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: filterapi.AuthorizationActionDeny, + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{ + BackendName: "backend1", + ToolName: "tool1", + Arguments: map[string]string{ + "mode": "fast", + }, + }}, + }, + Action: filterapi.AuthorizationActionAllow, + }, + }, + }, + header: "Bearer " + makeToken("read"), + backendName: "backend1", + toolName: "tool1", + args: map[string]any{}, + expectAllowed: false, + }, + { + name: "numeric argument matches via JSON string", + auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: filterapi.AuthorizationActionDeny, + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{ + BackendName: "backend1", + ToolName: "tool1", + Arguments: map[string]string{ + "count": "^4[0-9]$", + }, + }}, + }, + Action: filterapi.AuthorizationActionAllow, + }, + }, + }, + header: "Bearer " + makeToken("read"), + backendName: "backend1", + toolName: "tool1", + args: map[string]any{"count": 42}, + expectAllowed: true, + }, + { + name: "object argument can be matched via JSON string", + auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: filterapi.AuthorizationActionDeny, + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{ + BackendName: "backend1", + ToolName: "tool1", + Arguments: map[string]string{ + "payload": `"kind":"test"`, + }, + }}, + }, + Action: filterapi.AuthorizationActionAllow, + }, + }, + }, + header: "Bearer " + makeToken("read"), + backendName: "backend1", + toolName: "tool1", + args: map[string]any{ + "payload": map[string]any{"kind": "test", "value": 123}, + }, + expectAllowed: true, + }, { name: "matching tool but insufficient scopes not allowed", auth: &filterapi.MCPRouteAuthorization{ @@ -294,7 +445,7 @@ func TestAuthorizeRequest(t *testing.T) { if tt.header != "" { headers.Set("Authorization", tt.header) } - allowed := proxy.authorizeRequest(tt.auth, headers, tt.backendName, tt.toolName) + allowed := proxy.authorizeRequest(tt.auth, headers, tt.backendName, tt.toolName, tt.args) if allowed != tt.expectAllowed { t.Fatalf("expected %v, got %v", tt.expectAllowed, allowed) } diff --git a/internal/mcpproxy/handlers.go b/internal/mcpproxy/handlers.go index 6816b847d..0517aad98 100644 --- a/internal/mcpproxy/handlers.go +++ b/internal/mcpproxy/handlers.go @@ -542,7 +542,7 @@ func (m *MCPProxy) handleToolCallRequest(ctx context.Context, s *session, w http // Enforce authentication if required by the route. if route.authorization != nil { - if !m.authorizeRequest(route.authorization, headers, backendName, toolName) { + if !m.authorizeRequest(route.authorization, headers, backendName, toolName, p.Arguments) { onErrorResponse(w, http.StatusUnauthorized, "authorization failed") return fmt.Errorf("authorization failed") } 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 46afc361c..5c04918f3 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 @@ -642,6 +642,14 @@ spec: rule applies to. items: properties: + arguments: + additionalProperties: + type: string + description: |- + Arguments defines the arguments that must be present in the tool call for this rule to match. + Keys must exist and their values must match the provided RE2-compatible regular expressions. + If the argument is a non-string type, it will be matched against its JSON representation. + type: object backendName: description: BackendName is the name of the backend this tool belongs to. diff --git a/site/docs/api/api.mdx b/site/docs/api/api.mdx index 6ca19a07c..b6769763b 100644 --- a/site/docs/api/api.mdx +++ b/site/docs/api/api.mdx @@ -1985,6 +1985,11 @@ References: type="string" required="true" description="ToolName is the name of the tool." +/> diff --git a/tests/e2e/mcp_route_authorization_test.go b/tests/e2e/mcp_route_authorization_test.go index 856ff3048..3f97cb521 100644 --- a/tests/e2e/mcp_route_authorization_test.go +++ b/tests/e2e/mcp_route_authorization_test.go @@ -56,24 +56,89 @@ func TestMCPRouteAuthorization(t *testing.T) { 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, "echo", "sum") + 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("allow rules with 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("allow rules with matching scopes and mismatched arguments", func(t *testing.T) { + token := makeSignedJWT(t, "echo") authHTTPClient := &http.Client{ Timeout: 10 * time.Second, Transport: &bearerTokenTransport{ token: token, }, } - testMCPRouteTools( - t.Context(), - t, - client, - fwd.Address(), - "/mcp-authorization", - testMCPServerAllToolNames("mcp-backend-authorization__"), - authHTTPClient, - true, - true, - ) + + 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, "401") || strings.Contains(errMsg, "authorization"), "unexpected error: %v", err) }) t.Run("missing scopes fall back to deny", func(t *testing.T) { @@ -89,25 +154,9 @@ func TestMCPRouteAuthorization(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) t.Cleanup(cancel) - var sess *mcp.ClientSession - require.Eventually(t, func() bool { - var err error - sess, err = client.Connect( - ctx, - &mcp.StreamableClientTransport{ - Endpoint: fmt.Sprintf("%s/mcp-authorization", fwd.Address()), - HTTPClient: authHTTPClient, - }, 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") + sess := requireConnectMCP(ctx, t, client, fmt.Sprintf("%s/mcp-authorization", fwd.Address()), authHTTPClient) t.Cleanup(func() { - if sess != nil { - _ = sess.Close() - } + _ = sess.Close() }) _, err := sess.CallTool(ctx, &mcp.CallToolParams{ @@ -120,6 +169,25 @@ func TestMCPRouteAuthorization(t *testing.T) { }) } +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() diff --git a/tests/e2e/testdata/mcp_route_authorization.yaml b/tests/e2e/testdata/mcp_route_authorization.yaml index e09c2d221..91acd4156 100644 --- a/tests/e2e/testdata/mcp_route_authorization.yaml +++ b/tests/e2e/testdata/mcp_route_authorization.yaml @@ -69,6 +69,8 @@ spec: tools: - backendName: mcp-backend-authorization toolName: echo + arguments: + text: "^Hello, .*!$" action: Allow - source: jwtSource: From 19642326c4cc35505ccd3f0ecb584c20fa1749a3 Mon Sep 17 00:00:00 2001 From: Huabing Zhao Date: Tue, 2 Dec 2025 10:13:30 +0800 Subject: [PATCH 09/21] ignore codql jwt signature check Signed-off-by: Huabing Zhao --- internal/mcpproxy/authorization.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/mcpproxy/authorization.go b/internal/mcpproxy/authorization.go index e21e41bf0..62893ff2d 100644 --- a/internal/mcpproxy/authorization.go +++ b/internal/mcpproxy/authorization.go @@ -34,8 +34,9 @@ func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorizati } claims := jwt.MapClaims{} - // JWT verification is performed by Envoy before reaching here. So we only need to parse the token without verification. parser := jwt.NewParser(jwt.WithoutClaimsValidation()) + // JWT verification is performed by Envoy before reaching here. So we only need to parse the token without verification. + // codeql[go/missing-jwt-signature-check] if _, _, err := parser.ParseUnverified(token, claims); err != nil { m.l.Info("failed to parse JWT token", slog.String("error", err.Error())) return false From 24fd0ac4b05d0096f80e6d9d54b8a464b528bdd0 Mon Sep 17 00:00:00 2001 From: Huabing Zhao Date: Tue, 2 Dec 2025 10:35:38 +0800 Subject: [PATCH 10/21] update Signed-off-by: Huabing Zhao --- api/v1alpha1/mcp_route.go | 6 ++++-- api/v1alpha1/zz_generated.deepcopy.go | 6 +----- internal/mcpproxy/authorization.go | 4 +--- .../templates/aigateway.envoyproxy.io_mcproutes.yaml | 2 ++ 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/api/v1alpha1/mcp_route.go b/api/v1alpha1/mcp_route.go index c75ef15b5..8c341e6da 100644 --- a/api/v1alpha1/mcp_route.go +++ b/api/v1alpha1/mcp_route.go @@ -280,8 +280,10 @@ type MCPAuthorizationTarget struct { type MCPAuthorizationSource struct { // JWTSource defines the JWT scopes required for this rule to match. // - // +kubebuilder:validation:Optional - JWTSource *JWTSource `json:"jwtSource,omitempty"` + // +kubebuilder:validation:Required + JWTSource JWTSource `json:"jwtSource"` + + // TODO: JWTSource can be optional in the future when we support more source types. } type JWTSource struct { diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index ea363c652..c098d303c 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -958,11 +958,7 @@ func (in *LLMRequestCost) DeepCopy() *LLMRequestCost { // 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 - if in.JWTSource != nil { - in, out := &in.JWTSource, &out.JWTSource - *out = new(JWTSource) - (*in).DeepCopyInto(*out) - } + in.JWTSource.DeepCopyInto(&out.JWTSource) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPAuthorizationSource. diff --git a/internal/mcpproxy/authorization.go b/internal/mcpproxy/authorization.go index 62893ff2d..2b551b662 100644 --- a/internal/mcpproxy/authorization.go +++ b/internal/mcpproxy/authorization.go @@ -34,10 +34,8 @@ func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorizati } claims := jwt.MapClaims{} - parser := jwt.NewParser(jwt.WithoutClaimsValidation()) // JWT verification is performed by Envoy before reaching here. So we only need to parse the token without verification. - // codeql[go/missing-jwt-signature-check] - if _, _, err := parser.ParseUnverified(token, claims); err != nil { + if _, _, err := jwt.NewParser().ParseUnverified(token, claims); err != nil { m.l.Info("failed to parse JWT token", slog.String("error", err.Error())) return false } 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 5c04918f3..21218de3f 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 @@ -632,6 +632,8 @@ spec: required: - scopes type: object + required: + - jwtSource type: object target: description: Target defines the authorization target From 4e33aa4288d5192f3f69ff504dfaa10729747c5f Mon Sep 17 00:00:00 2001 From: Huabing Zhao Date: Tue, 2 Dec 2025 11:19:43 +0800 Subject: [PATCH 11/21] polish code Signed-off-by: Huabing Zhao --- api/v1alpha1/mcp_route.go | 4 ++++ internal/mcpproxy/authorization.go | 21 ++++++++++++------- .../aigateway.envoyproxy.io_mcproutes.yaml | 2 ++ site/docs/api/api.mdx | 8 +++---- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/api/v1alpha1/mcp_route.go b/api/v1alpha1/mcp_route.go index 8c341e6da..81ae440eb 100644 --- a/api/v1alpha1/mcp_route.go +++ b/api/v1alpha1/mcp_route.go @@ -268,6 +268,7 @@ type MCPRouteAuthorizationRule struct { Action egv1a1.AuthorizationAction `json:"action"` } +// MCPAuthorizationTarget defines the target of an authorization rule. type MCPAuthorizationTarget struct { // Tools defines the list of tools this rule applies to. // @@ -277,6 +278,7 @@ type MCPAuthorizationTarget struct { 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. // @@ -286,6 +288,7 @@ type MCPAuthorizationSource struct { // 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. @@ -298,6 +301,7 @@ type JWTSource struct { // 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. // diff --git a/internal/mcpproxy/authorization.go b/internal/mcpproxy/authorization.go index 2b551b662..9b0929969 100644 --- a/internal/mcpproxy/authorization.go +++ b/internal/mcpproxy/authorization.go @@ -14,11 +14,17 @@ import ( "strings" "github.com/golang-jwt/jwt/v5" + "k8s.io/apimachinery/pkg/util/sets" "github.com/envoyproxy/ai-gateway/internal/filterapi" ) +// authorizeRequest authorizes the request based on the given MCPRouteAuthorization configuration. func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorization, headers http.Header, backendName, toolName string, argments any) bool { + if authorization == nil { + return true + } + defaultAction := authorization.DefaultAction == filterapi.AuthorizationActionAllow // If there are no rules, return the default action. @@ -28,6 +34,8 @@ func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorizati // 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 @@ -40,10 +48,7 @@ func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorizati return false } - scopeSet := make(map[string]struct{}) - for _, scope := range extractScopes(claims) { - scopeSet[scope] = struct{}{} - } + scopeSet := sets.New[string](extractScopes(claims)...) for _, rule := range authorization.Rules { var args map[string]any @@ -52,7 +57,7 @@ func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorizati args = cast } } - if !toolMatches(args, filterapi.ToolCall{BackendName: backendName, ToolName: toolName}, rule.Target.Tools) { + if !m.toolMatches(filterapi.ToolCall{BackendName: backendName, ToolName: toolName}, rule.Target.Tools, args) { continue } if scopesSatisfied(scopeSet, rule.Source.JWTSource.Scopes) { @@ -104,7 +109,7 @@ func extractScopes(claims jwt.MapClaims) []string { } } -func toolMatches(args map[string]any, target filterapi.ToolCall, tools []filterapi.ToolCall) bool { +func (m *MCPProxy) toolMatches(target filterapi.ToolCall, tools []filterapi.ToolCall, args map[string]any) bool { if len(tools) == 0 { return true } @@ -128,6 +133,7 @@ func toolMatches(args map[string]any, target filterapi.ToolCall, tools []filtera } re, err := regexp.Compile(pattern) if err != nil { + m.l.Error("invalid argument regex pattern", slog.String("pattern", pattern), slog.String("error", err.Error())) allMatch = false break } @@ -137,6 +143,7 @@ func toolMatches(args map[string]any, target filterapi.ToolCall, tools []filtera } else { jsonVal, err := json.Marshal(rawVal) if err != nil { + m.l.Error("failed to marshal argument value to json", slog.String("key", key), slog.String("error", err.Error())) allMatch = false break } @@ -155,7 +162,7 @@ func toolMatches(args map[string]any, target filterapi.ToolCall, tools []filtera return false } -func scopesSatisfied(have map[string]struct{}, required []string) bool { +func scopesSatisfied(have sets.Set[string], required []string) bool { if len(required) == 0 { return true } 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 21218de3f..78c575f4e 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 @@ -643,6 +643,8 @@ spec: 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: additionalProperties: diff --git a/site/docs/api/api.mdx b/site/docs/api/api.mdx index b6769763b..9bcab5bf8 100644 --- a/site/docs/api/api.mdx +++ b/site/docs/api/api.mdx @@ -1456,7 +1456,7 @@ JWKS defines how to obtain JSON Web Key Sets (JWKS) either from a remote HTTP/HT **Appears in:** - [MCPAuthorizationSource](#mcpauthorizationsource) - +JWTSource defines the MCP authorization source for JWT tokens. ##### Fields @@ -1547,7 +1547,7 @@ LLMRequestCostType specifies the type of the LLMRequestCost. **Appears in:** - [MCPRouteAuthorizationRule](#mcprouteauthorizationrule) - +MCPAuthorizationSource defines the source of an authorization rule. ##### Fields @@ -1568,7 +1568,7 @@ LLMRequestCostType specifies the type of the LLMRequestCost. **Appears in:** - [MCPRouteAuthorizationRule](#mcprouteauthorizationrule) - +MCPAuthorizationTarget defines the target of an authorization rule. ##### Fields @@ -1969,7 +1969,7 @@ References: **Appears in:** - [MCPAuthorizationTarget](#mcpauthorizationtarget) - +ToolCall represents a tool call in the MCP authorization target. ##### Fields From e2d6c2cabc447b28e4a09f20f994f2a3f99f1340 Mon Sep 17 00:00:00 2001 From: Huabing Zhao Date: Tue, 2 Dec 2025 11:53:49 +0800 Subject: [PATCH 12/21] more unit tests Signed-off-by: Huabing Zhao --- internal/mcpproxy/authorization_test.go | 29 ++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/internal/mcpproxy/authorization_test.go b/internal/mcpproxy/authorization_test.go index 0b78010d9..610f1b163 100644 --- a/internal/mcpproxy/authorization_test.go +++ b/internal/mcpproxy/authorization_test.go @@ -206,7 +206,13 @@ func TestAuthorizeRequest(t *testing.T) { backendName: "backend1", toolName: "tool1", args: map[string]any{ - "payload": map[string]any{"kind": "test", "value": 123}, + "payload": struct { + Kind string `json:"kind"` + Value int `json:"value"` + }{ + Kind: "test", + Value: 123, + }, }, expectAllowed: true, }, @@ -377,6 +383,27 @@ func TestAuthorizeRequest(t *testing.T) { toolName: "tool1", expectAllowed: false, }, + { + name: "invalid bearer token not allowed when rules exist", + auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: filterapi.AuthorizationActionAllow, + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Source: filterapi.MCPAuthorizationSource{ + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + }, + Action: filterapi.AuthorizationActionDeny, + }, + }, + }, + header: "Bearer invalid.token.here", + backendName: "backend1", + toolName: "tool1", + expectAllowed: false, + }, { name: "multiple rules, first match applied - denied", auth: &filterapi.MCPRouteAuthorization{ From 30d41ea347e787123488893e12bc52588e526411 Mon Sep 17 00:00:00 2001 From: Huabing Zhao Date: Tue, 2 Dec 2025 12:07:05 +0800 Subject: [PATCH 13/21] use 403 in the response Signed-off-by: Huabing Zhao --- internal/mcpproxy/handlers.go | 2 +- tests/e2e/mcp_route_authorization_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/mcpproxy/handlers.go b/internal/mcpproxy/handlers.go index 0517aad98..6a8c3e6ea 100644 --- a/internal/mcpproxy/handlers.go +++ b/internal/mcpproxy/handlers.go @@ -543,7 +543,7 @@ func (m *MCPProxy) handleToolCallRequest(ctx context.Context, s *session, w http // Enforce authentication if required by the route. if route.authorization != nil { if !m.authorizeRequest(route.authorization, headers, backendName, toolName, p.Arguments) { - onErrorResponse(w, http.StatusUnauthorized, "authorization failed") + onErrorResponse(w, http.StatusForbidden, "authorization failed") return fmt.Errorf("authorization failed") } } diff --git a/tests/e2e/mcp_route_authorization_test.go b/tests/e2e/mcp_route_authorization_test.go index 3f97cb521..9f000ec73 100644 --- a/tests/e2e/mcp_route_authorization_test.go +++ b/tests/e2e/mcp_route_authorization_test.go @@ -138,7 +138,7 @@ func TestMCPRouteAuthorization(t *testing.T) { }) require.Error(t, err) errMsg := strings.ToLower(err.Error()) - require.True(t, strings.Contains(errMsg, "401") || strings.Contains(errMsg, "authorization"), "unexpected error: %v", err) + 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) { @@ -165,7 +165,7 @@ func TestMCPRouteAuthorization(t *testing.T) { }) require.Error(t, err) errMsg := strings.ToLower(err.Error()) - require.True(t, strings.Contains(errMsg, "401") || strings.Contains(errMsg, "authorization"), "unexpected error: %v", err) + require.True(t, strings.Contains(errMsg, "403") || strings.Contains(errMsg, "authorization"), "unexpected error: %v", err) }) } From 56443ef12d4c3a8acc05b19242609078aa73840a Mon Sep 17 00:00:00 2001 From: Huabing Zhao Date: Tue, 2 Dec 2025 13:09:36 +0800 Subject: [PATCH 14/21] Remove actions Signed-off-by: Huabing Zhao --- api/v1alpha1/mcp_route.go | 16 +- api/v1alpha1/zz_generated.deepcopy.go | 5 - internal/controller/gateway.go | 14 -- internal/filterapi/mcpconfig.go | 16 -- internal/mcpproxy/authorization.go | 10 +- internal/mcpproxy/authorization_test.go | 221 +++--------------- .../aigateway.envoyproxy.io_mcproutes.yaml | 22 +- site/docs/api/api.mdx | 12 +- tests/e2e/mcp_route_authorization_test.go | 4 +- .../e2e/testdata/mcp_route_authorization.yaml | 3 - 10 files changed, 47 insertions(+), 276 deletions(-) diff --git a/api/v1alpha1/mcp_route.go b/api/v1alpha1/mcp_route.go index 81ae440eb..343e9b1db 100644 --- a/api/v1alpha1/mcp_route.go +++ b/api/v1alpha1/mcp_route.go @@ -237,16 +237,13 @@ type MCPRouteOAuth struct { // MCPRouteAuthorization defines the authorization configuration for a MCPRoute. type MCPRouteAuthorization struct { // Rules defines a list of authorization rules. - // These rules are evaluated in order, the first matching rule will be applied, - // and the rest will be skipped. + // + // 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"` - - // DefaultAction defines the default action to be taken if no rules match. - // If not specified, the default action is Deny. - // +optional - DefaultAction *egv1a1.AuthorizationAction `json:"defaultAction"` } // MCPRouteAuthorizationRule defines an authorization rule for MCPRoute based on the MCP authorization spec. @@ -261,11 +258,6 @@ type MCPRouteAuthorizationRule struct { // // +kubebuilder:validation:Required Target MCPAuthorizationTarget `json:"target"` - - // Action defines whether to allow or deny requests that match this rule. - // - // +kubebuilder:validation:Required - Action egv1a1.AuthorizationAction `json:"action"` } // MCPAuthorizationTarget defines the target of an authorization rule. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index c098d303c..ae4e596c6 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -1080,11 +1080,6 @@ func (in *MCPRouteAuthorization) DeepCopyInto(out *MCPRouteAuthorization) { (*in)[i].DeepCopyInto(&(*out)[i]) } } - if in.DefaultAction != nil { - in, out := &in.DefaultAction, &out.DefaultAction - *out = new(apiv1alpha1.AuthorizationAction) - **out = **in - } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRouteAuthorization. diff --git a/internal/controller/gateway.go b/internal/controller/gateway.go index ff0d26660..016b38bb8 100644 --- a/internal/controller/gateway.go +++ b/internal/controller/gateway.go @@ -13,7 +13,6 @@ import ( "strings" "time" - egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1" "github.com/go-logr/logr" "github.com/google/uuid" appsv1 "k8s.io/api/apps/v1" @@ -477,19 +476,7 @@ func mcpConfig(mcpRoutes []aigv1a1.MCPRoute) *filterapi.MCPConfig { authorization := route.Spec.SecurityPolicy.Authorization mcpRoute.Authorization = &filterapi.MCPRouteAuthorization{} - defaultAction := ptr.Deref(authorization.DefaultAction, egv1a1.AuthorizationActionDeny) - if defaultAction == egv1a1.AuthorizationActionAllow { - mcpRoute.Authorization.DefaultAction = filterapi.AuthorizationActionAllow - } else { - mcpRoute.Authorization.DefaultAction = filterapi.AuthorizationActionDeny - } - for _, rule := range authorization.Rules { - action := filterapi.AuthorizationActionDeny - if rule.Action == egv1a1.AuthorizationActionAllow { - action = filterapi.AuthorizationActionAllow - } - scopes := make([]string, len(rule.Source.JWTSource.Scopes)) for i, scope := range rule.Source.JWTSource.Scopes { scopes[i] = string(scope) @@ -505,7 +492,6 @@ func mcpConfig(mcpRoutes []aigv1a1.MCPRoute) *filterapi.MCPConfig { } mcpRule := filterapi.MCPRouteAuthorizationRule{ - Action: action, Source: filterapi.MCPAuthorizationSource{ JWTSource: filterapi.JWTSource{ Scopes: scopes, diff --git a/internal/filterapi/mcpconfig.go b/internal/filterapi/mcpconfig.go index f3f686b0b..4221e80ee 100644 --- a/internal/filterapi/mcpconfig.go +++ b/internal/filterapi/mcpconfig.go @@ -68,9 +68,6 @@ type MCPRouteAuthorization struct { // These rules are evaluated in order, the first matching rule will be applied, // and the rest will be skipped. Rules []MCPRouteAuthorizationRule `json:"rules,omitempty"` - - // DefaultAction defines the action to take when no rules match. - DefaultAction AuthorizationAction `json:"defaultAction,omitempty"` } // MCPRouteAuthorizationRule defines an authorization rule for MCPRoute based on the MCP authorization spec. @@ -81,21 +78,8 @@ type MCPRouteAuthorizationRule struct { // Target defines the authorization target for this rule. Target MCPAuthorizationTarget `json:"target"` - - // Action defines whether to allow or deny requests that match this rule. - Action AuthorizationAction `json:"action"` } -// AuthorizationAction represents an authorization decision. -type AuthorizationAction string - -const ( - // AuthorizationActionAllow allows the request. - AuthorizationActionAllow AuthorizationAction = "Allow" - // AuthorizationActionDeny denies the request. - AuthorizationActionDeny AuthorizationAction = "Deny" -) - type MCPAuthorizationTarget struct { // Tools defines the list of tools this rule applies to. Tools []ToolCall `json:"tools"` diff --git a/internal/mcpproxy/authorization.go b/internal/mcpproxy/authorization.go index 9b0929969..55353320a 100644 --- a/internal/mcpproxy/authorization.go +++ b/internal/mcpproxy/authorization.go @@ -25,11 +25,9 @@ func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorizati return true } - defaultAction := authorization.DefaultAction == filterapi.AuthorizationActionAllow - - // If there are no rules, return the default action. + // If no rules are defined, deny all requests. if len(authorization.Rules) == 0 { - return defaultAction + return false } // If the rules are defined, a valid bearer token is required. @@ -61,11 +59,11 @@ func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorizati continue } if scopesSatisfied(scopeSet, rule.Source.JWTSource.Scopes) { - return rule.Action == filterapi.AuthorizationActionAllow + return true } } - return defaultAction + return false } func bearerToken(header string) (string, error) { diff --git a/internal/mcpproxy/authorization_test.go b/internal/mcpproxy/authorization_test.go index 610f1b163..f7831114c 100644 --- a/internal/mcpproxy/authorization_test.go +++ b/internal/mcpproxy/authorization_test.go @@ -40,9 +40,8 @@ func TestAuthorizeRequest(t *testing.T) { expectAllowed bool }{ { - name: "matching tool and scope allowed", + name: "matching tool and scope", auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: filterapi.AuthorizationActionDeny, Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ @@ -51,7 +50,6 @@ func TestAuthorizeRequest(t *testing.T) { Target: filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, }, - Action: filterapi.AuthorizationActionAllow, }, }, }, @@ -61,9 +59,8 @@ func TestAuthorizeRequest(t *testing.T) { expectAllowed: true, }, { - name: "matching tool scope and arguments regex allowed", + name: "matching tool scope and arguments regex", auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: filterapi.AuthorizationActionDeny, Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ @@ -80,7 +77,6 @@ func TestAuthorizeRequest(t *testing.T) { }, }}, }, - Action: filterapi.AuthorizationActionAllow, }, }, }, @@ -94,68 +90,9 @@ func TestAuthorizeRequest(t *testing.T) { }, expectAllowed: true, }, - { - name: "argument regex mismatch denied", - auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: filterapi.AuthorizationActionDeny, - Rules: []filterapi.MCPRouteAuthorizationRule{ - { - Source: filterapi.MCPAuthorizationSource{ - JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, - }, - Target: filterapi.MCPAuthorizationTarget{ - Tools: []filterapi.ToolCall{{ - BackendName: "backend1", - ToolName: "tool1", - Arguments: map[string]string{ - "mode": "fast|slow", - }, - }}, - }, - Action: filterapi.AuthorizationActionAllow, - }, - }, - }, - header: "Bearer " + makeToken("read"), - backendName: "backend1", - toolName: "tool1", - args: map[string]any{ - "mode": "other", - }, - expectAllowed: false, - }, - { - name: "missing argument denies when required", - auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: filterapi.AuthorizationActionDeny, - Rules: []filterapi.MCPRouteAuthorizationRule{ - { - Source: filterapi.MCPAuthorizationSource{ - JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, - }, - Target: filterapi.MCPAuthorizationTarget{ - Tools: []filterapi.ToolCall{{ - BackendName: "backend1", - ToolName: "tool1", - Arguments: map[string]string{ - "mode": "fast", - }, - }}, - }, - Action: filterapi.AuthorizationActionAllow, - }, - }, - }, - header: "Bearer " + makeToken("read"), - backendName: "backend1", - toolName: "tool1", - args: map[string]any{}, - expectAllowed: false, - }, { name: "numeric argument matches via JSON string", auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: filterapi.AuthorizationActionDeny, Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ @@ -170,7 +107,6 @@ func TestAuthorizeRequest(t *testing.T) { }, }}, }, - Action: filterapi.AuthorizationActionAllow, }, }, }, @@ -183,7 +119,6 @@ func TestAuthorizeRequest(t *testing.T) { { name: "object argument can be matched via JSON string", auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: filterapi.AuthorizationActionDeny, Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ @@ -198,7 +133,6 @@ func TestAuthorizeRequest(t *testing.T) { }, }}, }, - Action: filterapi.AuthorizationActionAllow, }, }, }, @@ -219,7 +153,6 @@ func TestAuthorizeRequest(t *testing.T) { { name: "matching tool but insufficient scopes not allowed", auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: filterapi.AuthorizationActionDeny, Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ @@ -228,7 +161,6 @@ func TestAuthorizeRequest(t *testing.T) { Target: filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, }, - Action: filterapi.AuthorizationActionAllow, }, }, }, @@ -238,72 +170,62 @@ func TestAuthorizeRequest(t *testing.T) { expectAllowed: false, }, { - name: "no matching rule falls back to default deny - tool mismatch", + name: "argument regex mismatch denied", auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: filterapi.AuthorizationActionDeny, Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, }, Target: filterapi.MCPAuthorizationTarget{ - Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + Tools: []filterapi.ToolCall{{ + BackendName: "backend1", + ToolName: "tool1", + Arguments: map[string]string{ + "mode": "fast|slow", + }, + }}, }, - Action: filterapi.AuthorizationActionAllow, }, }, }, - header: "Bearer " + makeToken("read", "write"), - backendName: "backend1", - toolName: "other-tool", - expectAllowed: false, - }, - { - name: "no matching rule falls back to default deny - scope mismatch", - auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: filterapi.AuthorizationActionDeny, - Rules: []filterapi.MCPRouteAuthorizationRule{ - { - Source: filterapi.MCPAuthorizationSource{ - JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, - }, - Target: filterapi.MCPAuthorizationTarget{ - Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, - }, - Action: filterapi.AuthorizationActionAllow, - }, - }, + header: "Bearer " + makeToken("read"), + backendName: "backend1", + toolName: "tool1", + args: map[string]any{ + "mode": "other", }, - header: "Bearer " + makeToken("foo", "bar"), - backendName: "backend1", - toolName: "other-tool", expectAllowed: false, }, { - name: "matching tool and scope denied", + name: "missing argument denies when required", auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: filterapi.AuthorizationActionAllow, Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ - JWTSource: filterapi.JWTSource{Scopes: []string{"delete"}}, + JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, }, Target: filterapi.MCPAuthorizationTarget{ - Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + Tools: []filterapi.ToolCall{{ + BackendName: "backend1", + ToolName: "tool1", + Arguments: map[string]string{ + "mode": "fast", + }, + }}, }, - Action: filterapi.AuthorizationActionDeny, }, }, }, - header: "Bearer " + makeToken("delete"), + header: "Bearer " + makeToken("read"), backendName: "backend1", toolName: "tool1", + args: map[string]any{}, expectAllowed: false, }, { - name: "no matching rule falls back to default allow - tool mismatch", + name: "no matching rule falls back to default deny - tool mismatch", auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: filterapi.AuthorizationActionAllow, Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ @@ -312,19 +234,17 @@ func TestAuthorizeRequest(t *testing.T) { Target: filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, }, - Action: filterapi.AuthorizationActionDeny, }, }, }, header: "Bearer " + makeToken("read", "write"), backendName: "backend1", toolName: "other-tool", - expectAllowed: true, + expectAllowed: false, }, { - name: "no matching rule falls back to default allow - scope mismatch", + name: "no matching rule falls back to default deny - scope mismatch", auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: filterapi.AuthorizationActionAllow, Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ @@ -333,30 +253,17 @@ func TestAuthorizeRequest(t *testing.T) { Target: filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, }, - Action: filterapi.AuthorizationActionDeny, }, }, }, header: "Bearer " + makeToken("foo", "bar"), backendName: "backend1", toolName: "other-tool", - expectAllowed: true, - }, - { - name: "no rules falls back to default allow", - auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: filterapi.AuthorizationActionAllow, - }, - header: "", - backendName: "backend1", - toolName: "tool1", - expectAllowed: true, + expectAllowed: false, }, { - name: "no rules falls back to default deny", - auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: filterapi.AuthorizationActionDeny, - }, + name: "no rules falls back to default deny", + auth: &filterapi.MCPRouteAuthorization{}, header: "", backendName: "backend1", toolName: "tool1", @@ -365,7 +272,6 @@ func TestAuthorizeRequest(t *testing.T) { { name: "no bearer token not allowed when rules exist", auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: filterapi.AuthorizationActionAllow, Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ @@ -374,7 +280,6 @@ func TestAuthorizeRequest(t *testing.T) { Target: filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, }, - Action: filterapi.AuthorizationActionDeny, }, }, }, @@ -386,7 +291,6 @@ func TestAuthorizeRequest(t *testing.T) { { name: "invalid bearer token not allowed when rules exist", auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: filterapi.AuthorizationActionAllow, Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ @@ -395,7 +299,6 @@ func TestAuthorizeRequest(t *testing.T) { Target: filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, }, - Action: filterapi.AuthorizationActionDeny, }, }, }, @@ -404,66 +307,6 @@ func TestAuthorizeRequest(t *testing.T) { toolName: "tool1", expectAllowed: false, }, - { - name: "multiple rules, first match applied - denied", - auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: filterapi.AuthorizationActionDeny, - Rules: []filterapi.MCPRouteAuthorizationRule{ - { - Source: filterapi.MCPAuthorizationSource{ - JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, - }, - Target: filterapi.MCPAuthorizationTarget{ - Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, - }, - Action: filterapi.AuthorizationActionDeny, - }, - { - Source: filterapi.MCPAuthorizationSource{ - JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, - }, - Target: filterapi.MCPAuthorizationTarget{ - Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, - }, - Action: filterapi.AuthorizationActionAllow, - }, - }, - }, - header: "Bearer " + makeToken("read", "write"), - backendName: "backend1", - toolName: "tool1", - expectAllowed: false, - }, - { - name: "multiple rules, first match applied - allowed", - auth: &filterapi.MCPRouteAuthorization{ - DefaultAction: filterapi.AuthorizationActionDeny, - Rules: []filterapi.MCPRouteAuthorizationRule{ - { - Source: filterapi.MCPAuthorizationSource{ - JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, - }, - Target: filterapi.MCPAuthorizationTarget{ - Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, - }, - Action: filterapi.AuthorizationActionAllow, - }, - { - Source: filterapi.MCPAuthorizationSource{ - JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, - }, - Target: filterapi.MCPAuthorizationTarget{ - Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, - }, - Action: filterapi.AuthorizationActionDeny, - }, - }, - }, - header: "Bearer " + makeToken("read", "write"), - backendName: "backend1", - toolName: "tool1", - expectAllowed: true, - }, } for _, tt := range tests { 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 78c575f4e..72b7d68b7 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 @@ -585,31 +585,18 @@ spec: description: Authorization defines the configuration for the MCP spec compatible authorization. properties: - defaultAction: - description: |- - DefaultAction defines the default action to be taken if no rules match. - If not specified, the default action is Deny. - enum: - - Allow - - Deny - type: string rules: description: |- Rules defines a list of authorization rules. - These rules are evaluated in order, the first matching rule will be applied, - and the rest will be skipped. + + 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: - action: - description: Action defines whether to allow or deny - requests that match this rule. - enum: - - Allow - - Deny - type: string source: description: Source defines the authorization source for this rule. @@ -672,7 +659,6 @@ spec: - tools type: object required: - - action - source - target type: object diff --git a/site/docs/api/api.mdx b/site/docs/api/api.mdx index 9bcab5bf8..d0ba5da64 100644 --- a/site/docs/api/api.mdx +++ b/site/docs/api/api.mdx @@ -1651,12 +1651,7 @@ MCPRouteAuthorization defines the authorization configuration for a MCPRoute. name="rules" type="[MCPRouteAuthorizationRule](#mcprouteauthorizationrule) array" required="false" - description="Rules defines a list of authorization rules.
These rules are evaluated in order, the first matching rule will be applied,
and the rest will be skipped." -/> @@ -1684,11 +1679,6 @@ Reference: https://modelcontextprotocol.io/specification/draft/basic/authorizati type="[MCPAuthorizationTarget](#mcpauthorizationtarget)" required="true" description="Target defines the authorization target for this rule." -/> diff --git a/tests/e2e/mcp_route_authorization_test.go b/tests/e2e/mcp_route_authorization_test.go index 9f000ec73..a808a5e22 100644 --- a/tests/e2e/mcp_route_authorization_test.go +++ b/tests/e2e/mcp_route_authorization_test.go @@ -84,7 +84,7 @@ func TestMCPRouteAuthorization(t *testing.T) { require.Equal(t, "42", txt.Text) }) - t.Run("allow rules with matching scopes and arguments", func(t *testing.T) { + t.Run("matching scopes and arguments", func(t *testing.T) { token := makeSignedJWT(t, "echo") authHTTPClient := &http.Client{ Timeout: 10 * time.Second, @@ -114,7 +114,7 @@ func TestMCPRouteAuthorization(t *testing.T) { require.Equal(t, hello, txt.Text) }) - t.Run("allow rules with matching scopes and mismatched arguments", func(t *testing.T) { + t.Run("matching scopes and mismatched arguments", func(t *testing.T) { token := makeSignedJWT(t, "echo") authHTTPClient := &http.Client{ Timeout: 10 * time.Second, diff --git a/tests/e2e/testdata/mcp_route_authorization.yaml b/tests/e2e/testdata/mcp_route_authorization.yaml index 91acd4156..f6ea8c165 100644 --- a/tests/e2e/testdata/mcp_route_authorization.yaml +++ b/tests/e2e/testdata/mcp_route_authorization.yaml @@ -59,7 +59,6 @@ spec: - "sum" - "countdown" authorization: - defaultAction: Deny rules: - source: jwtSource: @@ -71,7 +70,6 @@ spec: toolName: echo arguments: text: "^Hello, .*!$" - action: Allow - source: jwtSource: scopes: @@ -80,7 +78,6 @@ spec: tools: - backendName: mcp-backend-authorization toolName: sum - action: Allow --- # https://www.scottbrady.io/tools/jwt apiVersion: v1 From bf067aae3df772eb8968bbce98eb447b37d5c104 Mon Sep 17 00:00:00 2001 From: Huabing Zhao Date: Tue, 2 Dec 2025 14:28:56 +0800 Subject: [PATCH 15/21] add missing scopes in the response header Signed-off-by: Huabing Zhao --- internal/controller/gateway.go | 4 ++ .../controller/mcp_route_security_policy.go | 16 ++++-- internal/filterapi/mcpconfig.go | 9 +++- internal/mcpproxy/authorization.go | 39 +++++++++++--- internal/mcpproxy/authorization_test.go | 51 ++++++++++++++++++- internal/mcpproxy/handlers.go | 10 +++- .../authorization_without_oauth.yaml | 2 - tests/e2e/mcp_route_authorization_test.go | 41 +++++++++++++++ 8 files changed, 153 insertions(+), 19 deletions(-) diff --git a/internal/controller/gateway.go b/internal/controller/gateway.go index 016b38bb8..887376218 100644 --- a/internal/controller/gateway.go +++ b/internal/controller/gateway.go @@ -476,6 +476,10 @@ func mcpConfig(mcpRoutes []aigv1a1.MCPRoute) *filterapi.MCPConfig { 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 { diff --git a/internal/controller/mcp_route_security_policy.go b/internal/controller/mcp_route_security_policy.go index 1d2685c93..82f7d2be2 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 4221e80ee..4dd2420d8 100644 --- a/internal/filterapi/mcpconfig.go +++ b/internal/filterapi/mcpconfig.go @@ -65,9 +65,14 @@ type MCPRouteName = string // MCPRouteAuthorization defines the authorization configuration for a MCPRoute. type MCPRouteAuthorization struct { // Rules defines a list of authorization rules. - // These rules are evaluated in order, the first matching rule will be applied, - // and the rest will be skipped. + // 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. diff --git a/internal/mcpproxy/authorization.go b/internal/mcpproxy/authorization.go index 55353320a..51946da53 100644 --- a/internal/mcpproxy/authorization.go +++ b/internal/mcpproxy/authorization.go @@ -8,6 +8,7 @@ package mcpproxy import ( "encoding/json" "errors" + "fmt" "log/slog" "net/http" "regexp" @@ -20,14 +21,15 @@ import ( ) // authorizeRequest authorizes the request based on the given MCPRouteAuthorization configuration. -func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorization, headers http.Header, backendName, toolName string, argments any) bool { + +func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorization, headers http.Header, backendName, toolName string, argments any) (bool, []string) { if authorization == nil { - return true + return true, nil } // If no rules are defined, deny all requests. if len(authorization.Rules) == 0 { - return false + return false, nil } // If the rules are defined, a valid bearer token is required. @@ -36,17 +38,18 @@ func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorizati // should always be present and valid. if err != nil { m.l.Info("missing or invalid bearer token", slog.String("error", err.Error())) - return false + 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 + return false, nil } scopeSet := sets.New[string](extractScopes(claims)...) + var missingScopesForChallenge []string for _, rule := range authorization.Rules { var args map[string]any @@ -58,12 +61,19 @@ func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorizati if !m.toolMatches(filterapi.ToolCall{BackendName: backendName, ToolName: toolName}, rule.Target.Tools, args) { continue } - if scopesSatisfied(scopeSet, rule.Source.JWTSource.Scopes) { - return true + + requiredScopes := rule.Source.JWTSource.Scopes + if scopesSatisfied(scopeSet, requiredScopes) { + return true, nil + } + + // Keep track of the smallest set of missing scopes for challenge. + if len(missingScopesForChallenge) == 0 || len(requiredScopes) < len(missingScopesForChallenge) { + missingScopesForChallenge = requiredScopes } } - return false + return false, missingScopesForChallenge } func bearerToken(header string) (string, error) { @@ -172,3 +182,16 @@ func scopesSatisfied(have sets.Set[string], required []string) bool { } 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 index f7831114c..db8d6a57f 100644 --- a/internal/mcpproxy/authorization_test.go +++ b/internal/mcpproxy/authorization_test.go @@ -9,6 +9,7 @@ import ( "io" "log/slog" "net/http" + "reflect" "testing" "github.com/golang-jwt/jwt/v5" @@ -38,6 +39,7 @@ func TestAuthorizeRequest(t *testing.T) { toolName string args map[string]any expectAllowed bool + expectScopes []string }{ { name: "matching tool and scope", @@ -57,6 +59,7 @@ func TestAuthorizeRequest(t *testing.T) { backendName: "backend1", toolName: "tool1", expectAllowed: true, + expectScopes: nil, }, { name: "matching tool scope and arguments regex", @@ -89,6 +92,7 @@ func TestAuthorizeRequest(t *testing.T) { "debug": "true", }, expectAllowed: true, + expectScopes: nil, }, { name: "numeric argument matches via JSON string", @@ -115,6 +119,7 @@ func TestAuthorizeRequest(t *testing.T) { toolName: "tool1", args: map[string]any{"count": 42}, expectAllowed: true, + expectScopes: nil, }, { name: "object argument can be matched via JSON string", @@ -149,6 +154,7 @@ func TestAuthorizeRequest(t *testing.T) { }, }, expectAllowed: true, + expectScopes: nil, }, { name: "matching tool but insufficient scopes not allowed", @@ -168,6 +174,7 @@ func TestAuthorizeRequest(t *testing.T) { backendName: "backend1", toolName: "tool1", expectAllowed: false, + expectScopes: []string{"read", "write"}, }, { name: "argument regex mismatch denied", @@ -196,6 +203,7 @@ func TestAuthorizeRequest(t *testing.T) { "mode": "other", }, expectAllowed: false, + expectScopes: nil, }, { name: "missing argument denies when required", @@ -222,6 +230,7 @@ func TestAuthorizeRequest(t *testing.T) { toolName: "tool1", args: map[string]any{}, expectAllowed: false, + expectScopes: nil, }, { name: "no matching rule falls back to default deny - tool mismatch", @@ -241,6 +250,7 @@ func TestAuthorizeRequest(t *testing.T) { backendName: "backend1", toolName: "other-tool", expectAllowed: false, + expectScopes: nil, }, { name: "no matching rule falls back to default deny - scope mismatch", @@ -260,6 +270,7 @@ func TestAuthorizeRequest(t *testing.T) { backendName: "backend1", toolName: "other-tool", expectAllowed: false, + expectScopes: nil, }, { name: "no rules falls back to default deny", @@ -268,6 +279,7 @@ func TestAuthorizeRequest(t *testing.T) { backendName: "backend1", toolName: "tool1", expectAllowed: false, + expectScopes: nil, }, { name: "no bearer token not allowed when rules exist", @@ -287,6 +299,7 @@ func TestAuthorizeRequest(t *testing.T) { backendName: "backend1", toolName: "tool1", expectAllowed: false, + expectScopes: nil, }, { name: "invalid bearer token not allowed when rules exist", @@ -306,6 +319,27 @@ func TestAuthorizeRequest(t *testing.T) { 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"}, }, } @@ -315,10 +349,25 @@ func TestAuthorizeRequest(t *testing.T) { if tt.header != "" { headers.Set("Authorization", tt.header) } - allowed := proxy.authorizeRequest(tt.auth, headers, tt.backendName, tt.toolName, tt.args) + allowed, requiredScopes := proxy.authorizeRequest(tt.auth, 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) + } + }) +} diff --git a/internal/mcpproxy/handlers.go b/internal/mcpproxy/handlers.go index 6a8c3e6ea..aad3abf0b 100644 --- a/internal/mcpproxy/handlers.go +++ b/internal/mcpproxy/handlers.go @@ -542,7 +542,15 @@ func (m *MCPProxy) handleToolCallRequest(ctx context.Context, s *session, w http // Enforce authentication if required by the route. if route.authorization != nil { - if !m.authorizeRequest(route.authorization, headers, backendName, toolName, p.Arguments) { + 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") } diff --git a/tests/crdcel/testdata/mcpgatewayroutes/authorization_without_oauth.yaml b/tests/crdcel/testdata/mcpgatewayroutes/authorization_without_oauth.yaml index a336feb7d..e5b26ce6a 100644 --- a/tests/crdcel/testdata/mcpgatewayroutes/authorization_without_oauth.yaml +++ b/tests/crdcel/testdata/mcpgatewayroutes/authorization_without_oauth.yaml @@ -19,7 +19,6 @@ spec: port: 80 securityPolicy: authorization: - defaultAction: Deny rules: - source: jwtSource: @@ -29,4 +28,3 @@ spec: tools: - backendName: mcp-service toolName: echo - action: Allow diff --git a/tests/e2e/mcp_route_authorization_test.go b/tests/e2e/mcp_route_authorization_test.go index a808a5e22..e66063182 100644 --- a/tests/e2e/mcp_route_authorization_test.go +++ b/tests/e2e/mcp_route_authorization_test.go @@ -6,6 +6,7 @@ package e2e import ( + "bytes" "context" "crypto/rsa" "encoding/base64" @@ -167,6 +168,46 @@ func TestMCPRouteAuthorization(t *testing.T) { 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 { From 9e4df68b56ef7bc1fb7ee075ae959b87072d39c9 Mon Sep 17 00:00:00 2001 From: Huabing Zhao Date: Wed, 3 Dec 2025 10:50:30 +0800 Subject: [PATCH 16/21] use CEL for arguments matching Signed-off-by: Huabing Zhao --- api/v1alpha1/mcp_route.go | 10 +- api/v1alpha1/zz_generated.deepcopy.go | 6 +- internal/filterapi/mcpconfig.go | 6 +- internal/mcpproxy/authorization.go | 144 ++++++++++------ internal/mcpproxy/authorization_test.go | 157 ++++++++++++++---- internal/mcpproxy/mcpproxy.go | 9 +- .../aigateway.envoyproxy.io_mcproutes.yaml | 11 +- site/docs/api/api.mdx | 4 +- .../e2e/testdata/mcp_route_authorization.yaml | 3 +- 9 files changed, 247 insertions(+), 103 deletions(-) diff --git a/api/v1alpha1/mcp_route.go b/api/v1alpha1/mcp_route.go index 343e9b1db..fa4d26dab 100644 --- a/api/v1alpha1/mcp_route.go +++ b/api/v1alpha1/mcp_route.go @@ -305,12 +305,14 @@ type ToolCall struct { // +kubebuilder:validation:Required ToolName string `json:"toolName"` - // Arguments defines the arguments that must be present in the tool call for this rule to match. - // Keys must exist and their values must match the provided RE2-compatible regular expressions. - // If the argument is a non-string type, it will be matched against its JSON representation. + // 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 map[string]string `json:"arguments,omitempty"` + 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. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index ae4e596c6..e98c8872b 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -1371,10 +1371,8 @@ func (in *ToolCall) DeepCopyInto(out *ToolCall) { *out = *in if in.Arguments != nil { in, out := &in.Arguments, &out.Arguments - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } + *out = new(string) + **out = **in } } diff --git a/internal/filterapi/mcpconfig.go b/internal/filterapi/mcpconfig.go index 4dd2420d8..49a4c9132 100644 --- a/internal/filterapi/mcpconfig.go +++ b/internal/filterapi/mcpconfig.go @@ -108,7 +108,7 @@ type ToolCall struct { // ToolName is the name of the tool. ToolName string `json:"toolName"` - // Arguments defines required arguments (exact key names with regex patterns for values). - // All patterns must match for the rule to apply. - Arguments map[string]string `json:"arguments,omitempty"` + // 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 index 51946da53..d1d7e6d76 100644 --- a/internal/mcpproxy/authorization.go +++ b/internal/mcpproxy/authorization.go @@ -6,23 +6,88 @@ package mcpproxy import ( - "encoding/json" "errors" "fmt" "log/slog" "net/http" - "regexp" "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 *filterapi.MCPRouteAuthorization, headers http.Header, backendName, toolName string, argments any) (bool, []string) { +func (m *MCPProxy) authorizeRequest(authorization *compiledAuthorization, headers http.Header, backendName, toolName string, arguments any) (bool, []string) { if authorization == nil { return true, nil } @@ -48,17 +113,11 @@ func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorizati return false, nil } - scopeSet := sets.New[string](extractScopes(claims)...) - var missingScopesForChallenge []string + scopeSet := sets.New(extractScopes(claims)...) + var requiredScopesForChallenge []string for _, rule := range authorization.Rules { - var args map[string]any - if argments != nil { - if cast, ok := argments.(map[string]any); ok { - args = cast - } - } - if !m.toolMatches(filterapi.ToolCall{BackendName: backendName, ToolName: toolName}, rule.Target.Tools, args) { + if !m.toolMatches(backendName, toolName, rule.Target, arguments) { continue } @@ -67,13 +126,13 @@ func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorizati return true, nil } - // Keep track of the smallest set of missing scopes for challenge. - if len(missingScopesForChallenge) == 0 || len(requiredScopes) < len(missingScopesForChallenge) { - missingScopesForChallenge = requiredScopes + // Keep track of the smallest set of required scopes for challenge. + if len(requiredScopesForChallenge) == 0 || len(requiredScopes) < len(requiredScopesForChallenge) { + requiredScopesForChallenge = requiredScopes } } - return false, missingScopesForChallenge + return false, requiredScopesForChallenge } func bearerToken(header string) (string, error) { @@ -117,53 +176,36 @@ func extractScopes(claims jwt.MapClaims) []string { } } -func (m *MCPProxy) toolMatches(target filterapi.ToolCall, tools []filterapi.ToolCall, args map[string]any) bool { +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 != target.BackendName || t.ToolName != target.ToolName { + if t.BackendName != backendName || t.ToolName != toolName { continue } - if len(t.Arguments) == 0 { + if t.program == nil { return true } - if args == nil { - return false + + 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 } - allMatch := true - for key, pattern := range t.Arguments { - rawVal, ok := args[key] - if !ok { - allMatch = false - break - } - re, err := regexp.Compile(pattern) - if err != nil { - m.l.Error("invalid argument regex pattern", slog.String("pattern", pattern), slog.String("error", err.Error())) - allMatch = false - break - } - var data []byte - if s, ok := rawVal.(string); ok { - data = []byte(s) - } else { - jsonVal, err := json.Marshal(rawVal) - if err != nil { - m.l.Error("failed to marshal argument value to json", slog.String("key", key), slog.String("error", err.Error())) - allMatch = false - break - } - data = jsonVal + + switch v := result.Value().(type) { + case bool: + if v { + return true } - if !re.Match(data) { - allMatch = false - break + case types.Bool: + if bool(v) { + return true } - } - if allMatch { - 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. diff --git a/internal/mcpproxy/authorization_test.go b/internal/mcpproxy/authorization_test.go index db8d6a57f..11a351228 100644 --- a/internal/mcpproxy/authorization_test.go +++ b/internal/mcpproxy/authorization_test.go @@ -17,6 +17,8 @@ import ( "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{} @@ -38,6 +40,7 @@ func TestAuthorizeRequest(t *testing.T) { backendName string toolName string args map[string]any + expectError bool expectAllowed bool expectScopes []string }{ @@ -62,7 +65,7 @@ func TestAuthorizeRequest(t *testing.T) { expectScopes: nil, }, { - name: "matching tool scope and arguments regex", + name: "matching tool scope and arguments CEL", auth: &filterapi.MCPRouteAuthorization{ Rules: []filterapi.MCPRouteAuthorizationRule{ { @@ -73,11 +76,7 @@ func TestAuthorizeRequest(t *testing.T) { Tools: []filterapi.ToolCall{{ BackendName: "backend1", ToolName: "tool1", - Arguments: map[string]string{ - "mode": "fast|slow", - "user": "u-[0-9]+", - "debug": "true", - }, + Arguments: strPtr(`args.mode in ["fast", "slow"] && args.user.matches("u-[0-9]+") && args.debug == true`), }}, }, }, @@ -89,13 +88,13 @@ func TestAuthorizeRequest(t *testing.T) { args: map[string]any{ "mode": "fast", "user": "u-123", - "debug": "true", + "debug": true, }, expectAllowed: true, expectScopes: nil, }, { - name: "numeric argument matches via JSON string", + name: "numeric argument matches via CEL", auth: &filterapi.MCPRouteAuthorization{ Rules: []filterapi.MCPRouteAuthorizationRule{ { @@ -106,9 +105,7 @@ func TestAuthorizeRequest(t *testing.T) { Tools: []filterapi.ToolCall{{ BackendName: "backend1", ToolName: "tool1", - Arguments: map[string]string{ - "count": "^4[0-9]$", - }, + Arguments: strPtr(`int(args.count) >= 40 && int(args.count) < 50`), }}, }, }, @@ -122,7 +119,7 @@ func TestAuthorizeRequest(t *testing.T) { expectScopes: nil, }, { - name: "object argument can be matched via JSON string", + name: "object argument can be matched via CEL safe navigation", auth: &filterapi.MCPRouteAuthorization{ Rules: []filterapi.MCPRouteAuthorizationRule{ { @@ -133,9 +130,7 @@ func TestAuthorizeRequest(t *testing.T) { Tools: []filterapi.ToolCall{{ BackendName: "backend1", ToolName: "tool1", - Arguments: map[string]string{ - "payload": `"kind":"test"`, - }, + Arguments: strPtr(`args["payload"] != null && args["payload"]["kind"] == "test" && args["payload"]["value"] == 123`), }}, }, }, @@ -145,12 +140,9 @@ func TestAuthorizeRequest(t *testing.T) { backendName: "backend1", toolName: "tool1", args: map[string]any{ - "payload": struct { - Kind string `json:"kind"` - Value int `json:"value"` - }{ - Kind: "test", - Value: 123, + "payload": map[string]any{ + "kind": "test", + "value": 123, }, }, expectAllowed: true, @@ -177,7 +169,61 @@ func TestAuthorizeRequest(t *testing.T) { expectScopes: []string{"read", "write"}, }, { - name: "argument regex mismatch denied", + 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{ { @@ -188,9 +234,7 @@ func TestAuthorizeRequest(t *testing.T) { Tools: []filterapi.ToolCall{{ BackendName: "backend1", ToolName: "tool1", - Arguments: map[string]string{ - "mode": "fast|slow", - }, + Arguments: strPtr(`args.mode`), }}, }, }, @@ -205,6 +249,34 @@ func TestAuthorizeRequest(t *testing.T) { 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{ @@ -217,9 +289,7 @@ func TestAuthorizeRequest(t *testing.T) { Tools: []filterapi.ToolCall{{ BackendName: "backend1", ToolName: "tool1", - Arguments: map[string]string{ - "mode": "fast", - }, + Arguments: strPtr(`args["mode"] == "fast"`), }}, }, }, @@ -349,7 +419,14 @@ func TestAuthorizeRequest(t *testing.T) { if tt.header != "" { headers.Set("Authorization", tt.header) } - allowed, requiredScopes := proxy.authorizeRequest(tt.auth, headers, tt.backendName, tt.toolName, tt.args) + 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) } @@ -371,3 +448,25 @@ func TestBuildInsufficientScopeHeader(t *testing.T) { } }) } + +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/mcpproxy.go b/internal/mcpproxy/mcpproxy.go index 688fb3dab..861dc4713 100644 --- a/internal/mcpproxy/mcpproxy.go +++ b/internal/mcpproxy/mcpproxy.go @@ -53,7 +53,7 @@ type ( mcpProxyConfigRoute struct { backends map[filterapi.MCPBackendName]filterapi.MCPBackend toolSelectors map[filterapi.MCPBackendName]*toolSelector - authorization *filterapi.MCPRouteAuthorization + authorization *compiledAuthorization } // toolSelector filters tools using include patterns with exact matches or regular expressions. @@ -133,10 +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: route.Authorization, + 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 72b7d68b7..a0e24582e 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 @@ -634,13 +634,12 @@ spec: the MCP authorization target. properties: arguments: - additionalProperties: - type: string description: |- - Arguments defines the arguments that must be present in the tool call for this rule to match. - Keys must exist and their values must match the provided RE2-compatible regular expressions. - If the argument is a non-string type, it will be matched against its JSON representation. - type: object + 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. diff --git a/site/docs/api/api.mdx b/site/docs/api/api.mdx index d0ba5da64..da92294ad 100644 --- a/site/docs/api/api.mdx +++ b/site/docs/api/api.mdx @@ -1977,9 +1977,9 @@ ToolCall represents a tool call in the MCP authorization target. description="ToolName is the name of the tool." /> diff --git a/tests/e2e/testdata/mcp_route_authorization.yaml b/tests/e2e/testdata/mcp_route_authorization.yaml index f6ea8c165..ba9565d4f 100644 --- a/tests/e2e/testdata/mcp_route_authorization.yaml +++ b/tests/e2e/testdata/mcp_route_authorization.yaml @@ -68,8 +68,7 @@ spec: tools: - backendName: mcp-backend-authorization toolName: echo - arguments: - text: "^Hello, .*!$" + arguments: args.text.matches("^Hello, .*!$") - source: jwtSource: scopes: From 819b995783f4a079d6a8e3638c9e1d2e234157d7 Mon Sep 17 00:00:00 2001 From: Huabing Zhao Date: Thu, 4 Dec 2025 10:21:13 +0800 Subject: [PATCH 17/21] address comments Signed-off-by: Huabing Zhao --- api/v1alpha1/mcp_route.go | 8 +- api/v1alpha1/zz_generated.deepcopy.go | 2 +- internal/controller/gateway.go | 12 +- internal/filterapi/mcpconfig.go | 8 +- internal/mcpproxy/authorization.go | 24 +-- internal/mcpproxy/authorization_test.go | 164 +++++++++--------- internal/mcpproxy/handlers.go | 2 +- .../aigateway.envoyproxy.io_mcproutes.yaml | 14 +- site/docs/api/api.mdx | 8 +- .../authorization_without_oauth.yaml | 4 +- .../e2e/testdata/mcp_route_authorization.yaml | 8 +- 11 files changed, 127 insertions(+), 127 deletions(-) diff --git a/api/v1alpha1/mcp_route.go b/api/v1alpha1/mcp_route.go index fa4d26dab..ef9dbb9bd 100644 --- a/api/v1alpha1/mcp_route.go +++ b/api/v1alpha1/mcp_route.go @@ -272,10 +272,10 @@ type MCPAuthorizationTarget struct { // MCPAuthorizationSource defines the source of an authorization rule. type MCPAuthorizationSource struct { - // JWTSource defines the JWT scopes required for this rule to match. + // JWT defines the JWT scopes required for this rule to match. // // +kubebuilder:validation:Required - JWTSource JWTSource `json:"jwtSource"` + JWT JWTSource `json:"jwt"` // TODO: JWTSource can be optional in the future when we support more source types. } @@ -295,10 +295,10 @@ type JWTSource struct { // ToolCall represents a tool call in the MCP authorization target. type ToolCall struct { - // BackendName is the name of the backend this tool belongs to. + // Backend is the name of the backend this tool belongs to. // // +kubebuilder:validation:Required - BackendName string `json:"backendName"` + Backend string `json:"backend"` // ToolName is the name of the tool. // diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index e98c8872b..532f15857 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -958,7 +958,7 @@ func (in *LLMRequestCost) DeepCopy() *LLMRequestCost { // 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) + in.JWT.DeepCopyInto(&out.JWT) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPAuthorizationSource. diff --git a/internal/controller/gateway.go b/internal/controller/gateway.go index 887376218..ff0778079 100644 --- a/internal/controller/gateway.go +++ b/internal/controller/gateway.go @@ -481,23 +481,23 @@ func mcpConfig(mcpRoutes []aigv1a1.MCPRoute) *filterapi.MCPConfig { } for _, rule := range authorization.Rules { - scopes := make([]string, len(rule.Source.JWTSource.Scopes)) - for i, scope := range rule.Source.JWTSource.Scopes { + scopes := make([]string, len(rule.Source.JWT.Scopes)) + for i, scope := range rule.Source.JWT.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, + Backend: tool.Backend, + ToolName: tool.ToolName, + Arguments: tool.Arguments, } } mcpRule := filterapi.MCPRouteAuthorizationRule{ Source: filterapi.MCPAuthorizationSource{ - JWTSource: filterapi.JWTSource{ + JWT: filterapi.JWTSource{ Scopes: scopes, }, }, diff --git a/internal/filterapi/mcpconfig.go b/internal/filterapi/mcpconfig.go index 49a4c9132..36bce59c0 100644 --- a/internal/filterapi/mcpconfig.go +++ b/internal/filterapi/mcpconfig.go @@ -91,8 +91,8 @@ type MCPAuthorizationTarget struct { } type MCPAuthorizationSource struct { - // JWTSource defines the JWT scopes required for this rule to match. - JWTSource JWTSource `json:"jwtSource,omitempty"` + // JWT defines the JWT scopes required for this rule to match. + JWT JWTSource `json:"jwt,omitempty"` } type JWTSource struct { @@ -102,8 +102,8 @@ type JWTSource struct { } type ToolCall struct { - // BackendName is the name of the backend this tool belongs to. - BackendName string `json:"backendName"` + // Backend is the name of the backend this tool belongs to. + Backend string `json:"backend"` // ToolName is the name of the tool. ToolName string `json:"toolName"` diff --git a/internal/mcpproxy/authorization.go b/internal/mcpproxy/authorization.go index d1d7e6d76..f6234d273 100644 --- a/internal/mcpproxy/authorization.go +++ b/internal/mcpproxy/authorization.go @@ -31,10 +31,10 @@ type compiledAuthorizationRule struct { } type compiledToolCall struct { - BackendName string - ToolName string - Expression string - program cel.Program + Backend string + ToolName string + Expression string + program cel.Program } // compileAuthorization compiles the MCPRouteAuthorization into a compiledAuthorization for efficient CEL evaluation. @@ -61,18 +61,18 @@ func compileAuthorization(auth *filterapi.MCPRouteAuthorization) (*compiledAutho } for _, tool := range rule.Target.Tools { ct := compiledToolCall{ - BackendName: tool.BackendName, - ToolName: tool.ToolName, + Backend: tool.Backend, + 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()) + return nil, fmt.Errorf("failed to compile arguments CEL for tool %s/%s: %w", tool.Backend, 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) + return nil, fmt.Errorf("failed to build arguments CEL program for tool %s/%s: %w", tool.Backend, tool.ToolName, err) } ct.Expression = expr ct.program = program @@ -121,7 +121,7 @@ func (m *MCPProxy) authorizeRequest(authorization *compiledAuthorization, header continue } - requiredScopes := rule.Source.JWTSource.Scopes + requiredScopes := rule.Source.JWT.Scopes if scopesSatisfied(scopeSet, requiredScopes) { return true, nil } @@ -182,7 +182,7 @@ func (m *MCPProxy) toolMatches(backendName, toolName string, tools []compiledToo } for _, t := range tools { - if t.BackendName != backendName || t.ToolName != toolName { + if t.Backend != backendName || t.ToolName != toolName { continue } if t.program == nil { @@ -191,7 +191,7 @@ func (m *MCPProxy) toolMatches(backendName, toolName string, tools []compiledToo 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())) + m.l.Error("failed to evaluate arguments CEL", slog.String("backend", t.Backend), slog.String("tool", t.ToolName), slog.String("error", err.Error())) continue } @@ -205,7 +205,7 @@ func (m *MCPProxy) toolMatches(backendName, toolName string, tools []compiledToo 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)) + m.l.Error("arguments CEL did not return a boolean", slog.String("backend", t.Backend), slog.String("tool", t.ToolName), slog.String("expression", t.Expression)) } } // If no matching tool entry or no arguments matched, fail. diff --git a/internal/mcpproxy/authorization_test.go b/internal/mcpproxy/authorization_test.go index 11a351228..7c978d153 100644 --- a/internal/mcpproxy/authorization_test.go +++ b/internal/mcpproxy/authorization_test.go @@ -37,7 +37,7 @@ func TestAuthorizeRequest(t *testing.T) { name string auth *filterapi.MCPRouteAuthorization header string - backendName string + backend string toolName string args map[string]any expectError bool @@ -50,16 +50,16 @@ func TestAuthorizeRequest(t *testing.T) { Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ - JWTSource: filterapi.JWTSource{Scopes: []string{"read", "write"}}, + JWT: filterapi.JWTSource{Scopes: []string{"read", "write"}}, }, Target: filterapi.MCPAuthorizationTarget{ - Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}, }, }, }, }, header: "Bearer " + makeToken("read", "write"), - backendName: "backend1", + backend: "backend1", toolName: "tool1", expectAllowed: true, expectScopes: nil, @@ -70,21 +70,21 @@ func TestAuthorizeRequest(t *testing.T) { Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ - JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + JWT: 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`), + Backend: "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", + header: "Bearer " + makeToken("read"), + backend: "backend1", + toolName: "tool1", args: map[string]any{ "mode": "fast", "user": "u-123", @@ -99,20 +99,20 @@ func TestAuthorizeRequest(t *testing.T) { Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ - JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + JWT: 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`), + Backend: "backend1", + ToolName: "tool1", + Arguments: strPtr(`int(args.count) >= 40 && int(args.count) < 50`), }}, }, }, }, }, header: "Bearer " + makeToken("read"), - backendName: "backend1", + backend: "backend1", toolName: "tool1", args: map[string]any{"count": 42}, expectAllowed: true, @@ -124,21 +124,21 @@ func TestAuthorizeRequest(t *testing.T) { Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ - JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + JWT: 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`), + Backend: "backend1", + ToolName: "tool1", + Arguments: strPtr(`args["payload"] != null && args["payload"]["kind"] == "test" && args["payload"]["value"] == 123`), }}, }, }, }, }, - header: "Bearer " + makeToken("read"), - backendName: "backend1", - toolName: "tool1", + header: "Bearer " + makeToken("read"), + backend: "backend1", + toolName: "tool1", args: map[string]any{ "payload": map[string]any{ "kind": "test", @@ -154,16 +154,16 @@ func TestAuthorizeRequest(t *testing.T) { Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ - JWTSource: filterapi.JWTSource{Scopes: []string{"read", "write"}}, + JWT: filterapi.JWTSource{Scopes: []string{"read", "write"}}, }, Target: filterapi.MCPAuthorizationTarget{ - Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}, }, }, }, }, header: "Bearer " + makeToken("read"), - backendName: "backend1", + backend: "backend1", toolName: "tool1", expectAllowed: false, expectScopes: []string{"read", "write"}, @@ -174,21 +174,21 @@ func TestAuthorizeRequest(t *testing.T) { Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ - JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, Target: filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ - BackendName: "backend1", - ToolName: "tool1", - Arguments: strPtr(`args.mode in ["fast", "slow"]`), + Backend: "backend1", + ToolName: "tool1", + Arguments: strPtr(`args.mode in ["fast", "slow"]`), }}, }, }, }, }, - header: "Bearer " + makeToken("read"), - backendName: "backend1", - toolName: "tool1", + header: "Bearer " + makeToken("read"), + backend: "backend1", + toolName: "tool1", args: map[string]any{ "mode": "other", }, @@ -201,21 +201,21 @@ func TestAuthorizeRequest(t *testing.T) { Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ - JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, Target: filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ - BackendName: "backend1", - ToolName: "tool1", - Arguments: strPtr(`args.nonExistingField in ["fast", "slow"]`), + Backend: "backend1", + ToolName: "tool1", + Arguments: strPtr(`args.nonExistingField in ["fast", "slow"]`), }}, }, }, }, }, - header: "Bearer " + makeToken("read"), - backendName: "backend1", - toolName: "tool1", + header: "Bearer " + makeToken("read"), + backend: "backend1", + toolName: "tool1", args: map[string]any{ "mode": "other", }, @@ -228,21 +228,21 @@ func TestAuthorizeRequest(t *testing.T) { Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ - JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, Target: filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ - BackendName: "backend1", - ToolName: "tool1", - Arguments: strPtr(`args.mode`), + Backend: "backend1", + ToolName: "tool1", + Arguments: strPtr(`args.mode`), }}, }, }, }, }, - header: "Bearer " + makeToken("read"), - backendName: "backend1", - toolName: "tool1", + header: "Bearer " + makeToken("read"), + backend: "backend1", + toolName: "tool1", args: map[string]any{ "mode": "other", }, @@ -255,21 +255,21 @@ func TestAuthorizeRequest(t *testing.T) { Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ - JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, Target: filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ - BackendName: "backend1", - ToolName: "tool1", - Arguments: strPtr(`invalid syntax here`), + Backend: "backend1", + ToolName: "tool1", + Arguments: strPtr(`invalid syntax here`), }}, }, }, }, }, - header: "Bearer " + makeToken("read"), - backendName: "backend1", - toolName: "tool1", + header: "Bearer " + makeToken("read"), + backend: "backend1", + toolName: "tool1", args: map[string]any{ "mode": "other", }, @@ -283,20 +283,20 @@ func TestAuthorizeRequest(t *testing.T) { Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ - JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, Target: filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ - BackendName: "backend1", - ToolName: "tool1", - Arguments: strPtr(`args["mode"] == "fast"`), + Backend: "backend1", + ToolName: "tool1", + Arguments: strPtr(`args["mode"] == "fast"`), }}, }, }, }, }, header: "Bearer " + makeToken("read"), - backendName: "backend1", + backend: "backend1", toolName: "tool1", args: map[string]any{}, expectAllowed: false, @@ -308,16 +308,16 @@ func TestAuthorizeRequest(t *testing.T) { Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ - JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, Target: filterapi.MCPAuthorizationTarget{ - Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}, }, }, }, }, header: "Bearer " + makeToken("read", "write"), - backendName: "backend1", + backend: "backend1", toolName: "other-tool", expectAllowed: false, expectScopes: nil, @@ -328,16 +328,16 @@ func TestAuthorizeRequest(t *testing.T) { Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ - JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, Target: filterapi.MCPAuthorizationTarget{ - Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}, }, }, }, }, header: "Bearer " + makeToken("foo", "bar"), - backendName: "backend1", + backend: "backend1", toolName: "other-tool", expectAllowed: false, expectScopes: nil, @@ -346,7 +346,7 @@ func TestAuthorizeRequest(t *testing.T) { name: "no rules falls back to default deny", auth: &filterapi.MCPRouteAuthorization{}, header: "", - backendName: "backend1", + backend: "backend1", toolName: "tool1", expectAllowed: false, expectScopes: nil, @@ -357,16 +357,16 @@ func TestAuthorizeRequest(t *testing.T) { Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ - JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, Target: filterapi.MCPAuthorizationTarget{ - Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}, }, }, }, }, header: "", - backendName: "backend1", + backend: "backend1", toolName: "tool1", expectAllowed: false, expectScopes: nil, @@ -377,16 +377,16 @@ func TestAuthorizeRequest(t *testing.T) { Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ - JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, Target: filterapi.MCPAuthorizationTarget{ - Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}, + Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}, }, }, }, }, header: "Bearer invalid.token.here", - backendName: "backend1", + backend: "backend1", toolName: "tool1", expectAllowed: false, expectScopes: nil, @@ -396,17 +396,17 @@ func TestAuthorizeRequest(t *testing.T) { 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{JWT: filterapi.JWTSource{Scopes: []string{"alpha", "beta", "gamma"}}}, + Target: filterapi.MCPAuthorizationTarget{Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}}, }, { - Source: filterapi.MCPAuthorizationSource{JWTSource: filterapi.JWTSource{Scopes: []string{"alpha", "beta"}}}, - Target: filterapi.MCPAuthorizationTarget{Tools: []filterapi.ToolCall{{BackendName: "backend1", ToolName: "tool1"}}}, + Source: filterapi.MCPAuthorizationSource{JWT: filterapi.JWTSource{Scopes: []string{"alpha", "beta"}}}, + Target: filterapi.MCPAuthorizationTarget{Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}}, }, }, }, header: "Bearer " + makeToken("alpha"), - backendName: "backend1", + backend: "backend1", toolName: "tool1", expectAllowed: false, expectScopes: []string{"alpha", "beta"}, @@ -426,7 +426,7 @@ func TestAuthorizeRequest(t *testing.T) { if err != nil { return } - allowed, requiredScopes := proxy.authorizeRequest(compiled, headers, tt.backendName, tt.toolName, tt.args) + allowed, requiredScopes := proxy.authorizeRequest(compiled, headers, tt.backend, tt.toolName, tt.args) if allowed != tt.expectAllowed { t.Fatalf("expected %v, got %v", tt.expectAllowed, allowed) } @@ -454,13 +454,13 @@ func TestCompileAuthorizationInvalidExpression(t *testing.T) { Rules: []filterapi.MCPRouteAuthorizationRule{ { Source: filterapi.MCPAuthorizationSource{ - JWTSource: filterapi.JWTSource{Scopes: []string{"read"}}, + JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, Target: filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ - BackendName: "backend1", - ToolName: "tool1", - Arguments: strPtr("args."), + Backend: "backend1", + ToolName: "tool1", + Arguments: strPtr("args."), }}, }, }, diff --git a/internal/mcpproxy/handlers.go b/internal/mcpproxy/handlers.go index aad3abf0b..34a06efdd 100644 --- a/internal/mcpproxy/handlers.go +++ b/internal/mcpproxy/handlers.go @@ -551,7 +551,7 @@ func (m *MCPProxy) handleToolCallRequest(ctx context.Context, s *session, w http w.Header().Set("WWW-Authenticate", challenge) } } - onErrorResponse(w, http.StatusForbidden, "authorization failed") + onErrorResponse(w, http.StatusForbidden, "access denied") return fmt.Errorf("authorization failed") } } 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 a0e24582e..6e424867e 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 @@ -601,8 +601,8 @@ spec: description: Source defines the authorization source for this rule. properties: - jwtSource: - description: JWTSource defines the JWT scopes required + jwt: + description: JWT defines the JWT scopes required for this rule to match. properties: scopes: @@ -620,7 +620,7 @@ spec: - scopes type: object required: - - jwtSource + - jwt type: object target: description: Target defines the authorization target @@ -640,15 +640,15 @@ spec: 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. + backend: + description: Backend 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 + - backend - toolName type: object maxItems: 16 diff --git a/site/docs/api/api.mdx b/site/docs/api/api.mdx index da92294ad..1ebf513d4 100644 --- a/site/docs/api/api.mdx +++ b/site/docs/api/api.mdx @@ -1554,10 +1554,10 @@ MCPAuthorizationSource defines the source of an authorization rule. @@ -1966,10 +1966,10 @@ ToolCall represents a tool call in the MCP authorization target. Date: Thu, 4 Dec 2025 11:22:38 +0800 Subject: [PATCH 18/21] fix test Signed-off-by: Huabing Zhao --- internal/mcpproxy/authorization.go | 1 - tests/e2e/mcp_route_authorization_test.go | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/internal/mcpproxy/authorization.go b/internal/mcpproxy/authorization.go index f6234d273..8ab425d4e 100644 --- a/internal/mcpproxy/authorization.go +++ b/internal/mcpproxy/authorization.go @@ -86,7 +86,6 @@ func compileAuthorization(auth *filterapi.MCPRouteAuthorization) (*compiledAutho } // 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 diff --git a/tests/e2e/mcp_route_authorization_test.go b/tests/e2e/mcp_route_authorization_test.go index e66063182..06158848f 100644 --- a/tests/e2e/mcp_route_authorization_test.go +++ b/tests/e2e/mcp_route_authorization_test.go @@ -139,7 +139,7 @@ func TestMCPRouteAuthorization(t *testing.T) { }) require.Error(t, err) errMsg := strings.ToLower(err.Error()) - require.True(t, strings.Contains(errMsg, "403") || strings.Contains(errMsg, "authorization"), "unexpected error: %v", err) + require.Contains(t, errMsg, "forbidden", "unexpected error: %v", err) }) t.Run("missing scopes fall back to deny", func(t *testing.T) { @@ -166,7 +166,7 @@ func TestMCPRouteAuthorization(t *testing.T) { }) require.Error(t, err) errMsg := strings.ToLower(err.Error()) - require.True(t, strings.Contains(errMsg, "403") || strings.Contains(errMsg, "authorization"), "unexpected error: %v", err) + require.Contains(t, errMsg, "forbidden", "unexpected error: %v", err) }) t.Run("WWW-Authenticate on insufficient scope", func(t *testing.T) { From b8e32a614930cf2c4266765648419e73a5ceab88 Mon Sep 17 00:00:00 2001 From: Huabing Zhao Date: Thu, 4 Dec 2025 14:55:36 +0800 Subject: [PATCH 19/21] support defaultAction and action in rules Signed-off-by: Huabing Zhao --- api/v1alpha1/mcp_route.go | 30 ++- api/v1alpha1/zz_generated.deepcopy.go | 22 +- internal/controller/gateway.go | 20 +- internal/filterapi/mcpconfig.go | 23 +- internal/mcpproxy/authorization.go | 85 +++--- internal/mcpproxy/authorization_test.go | 242 ++++++++++++++---- .../aigateway.envoyproxy.io_mcproutes.yaml | 35 ++- site/docs/api/api.mdx | 18 +- 8 files changed, 366 insertions(+), 109 deletions(-) diff --git a/api/v1alpha1/mcp_route.go b/api/v1alpha1/mcp_route.go index ef9dbb9bd..ab07af6ee 100644 --- a/api/v1alpha1/mcp_route.go +++ b/api/v1alpha1/mcp_route.go @@ -236,11 +236,18 @@ type MCPRouteOAuth struct { // MCPRouteAuthorization defines the authorization configuration for a MCPRoute. type MCPRouteAuthorization struct { + // DefaultAction is the action to take when no rules match. If unspecified, defaults to Deny. + // + // +kubebuilder:validation:Optional + // +kubebuilder:default:=Deny + // +optional + DefaultAction *egv1a1.AuthorizationAction `json:"defaultAction,omitempty"` + // Rules defines a list of authorization rules. + // These rules are evaluated in order, the first matching rule will be applied, + // and the rest will be skipped. // - // 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. + // If no rules are defined, the default action will be applied to all requests. // // +optional Rules []MCPRouteAuthorizationRule `json:"rules,omitempty"` @@ -250,14 +257,23 @@ type MCPRouteAuthorization struct { // Reference: https://modelcontextprotocol.io/specification/draft/basic/authorization#scope-challenge-handling type MCPRouteAuthorizationRule struct { // Source defines the authorization source for this rule. + // If not specified, the rule will match all sources. // - // +kubebuilder:validation:Required - Source MCPAuthorizationSource `json:"source"` + // +kubebuilder:validation:Optional + Source *MCPAuthorizationSource `json:"source,omitempty"` // Target defines the authorization target for this rule. + // If not specified, the rule will match all targets. // - // +kubebuilder:validation:Required - Target MCPAuthorizationTarget `json:"target"` + // +kubebuilder:validation:Optional + Target *MCPAuthorizationTarget `json:"target,omitempty"` + + // Action is the authorization decision for matching requests. If unspecified, defaults to Allow. + // + // +kubebuilder:validation:Optional + // +kubebuilder:default:=Allow + // +optional + Action *egv1a1.AuthorizationAction `json:"action,omitempty"` } // MCPAuthorizationTarget defines the target of an authorization rule. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 532f15857..03e8dee58 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -1073,6 +1073,11 @@ func (in *MCPRoute) DeepCopyObject() runtime.Object { // 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.DefaultAction != nil { + in, out := &in.DefaultAction, &out.DefaultAction + *out = new(apiv1alpha1.AuthorizationAction) + **out = **in + } if in.Rules != nil { in, out := &in.Rules, &out.Rules *out = make([]MCPRouteAuthorizationRule, len(*in)) @@ -1095,8 +1100,21 @@ func (in *MCPRouteAuthorization) DeepCopy() *MCPRouteAuthorization { // 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) + if in.Source != nil { + in, out := &in.Source, &out.Source + *out = new(MCPAuthorizationSource) + (*in).DeepCopyInto(*out) + } + if in.Target != nil { + in, out := &in.Target, &out.Target + *out = new(MCPAuthorizationTarget) + (*in).DeepCopyInto(*out) + } + if in.Action != nil { + in, out := &in.Action, &out.Action + *out = new(apiv1alpha1.AuthorizationAction) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRouteAuthorizationRule. diff --git a/internal/controller/gateway.go b/internal/controller/gateway.go index ff0778079..4ec6d5ff8 100644 --- a/internal/controller/gateway.go +++ b/internal/controller/gateway.go @@ -13,6 +13,7 @@ import ( "strings" "time" + egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1" "github.com/go-logr/logr" "github.com/google/uuid" appsv1 "k8s.io/api/apps/v1" @@ -480,10 +481,13 @@ func mcpConfig(mcpRoutes []aigv1a1.MCPRoute) *filterapi.MCPConfig { mcpRoute.Authorization.ResourceMetadataURL = buildResourceMetadataURL(&route.Spec.SecurityPolicy.OAuth.ProtectedResourceMetadata) } + defaultAction := ptr.Deref(authorization.DefaultAction, egv1a1.AuthorizationActionDeny) + mcpRoute.Authorization.DefaultAction = filterapi.AuthorizationAction(defaultAction) + for _, rule := range authorization.Rules { - scopes := make([]string, len(rule.Source.JWT.Scopes)) - for i, scope := range rule.Source.JWT.Scopes { - scopes[i] = string(scope) + action := ptr.Deref(rule.Action, egv1a1.AuthorizationActionAllow) + if mcpRoute.Authorization.Rules == nil { + mcpRoute.Authorization.Rules = []filterapi.MCPRouteAuthorizationRule{} } tools := make([]filterapi.ToolCall, len(rule.Target.Tools)) @@ -495,15 +499,21 @@ func mcpConfig(mcpRoutes []aigv1a1.MCPRoute) *filterapi.MCPConfig { } } + scopes := make([]string, len(rule.Source.JWT.Scopes)) + for i, scope := range rule.Source.JWT.Scopes { + scopes[i] = string(scope) + } + mcpRule := filterapi.MCPRouteAuthorizationRule{ - Source: filterapi.MCPAuthorizationSource{ + Source: &filterapi.MCPAuthorizationSource{ JWT: filterapi.JWTSource{ Scopes: scopes, }, }, - Target: filterapi.MCPAuthorizationTarget{ + Target: &filterapi.MCPAuthorizationTarget{ Tools: tools, }, + Action: filterapi.AuthorizationAction(action), } mcpRoute.Authorization.Rules = append(mcpRoute.Authorization.Rules, mcpRule) } diff --git a/internal/filterapi/mcpconfig.go b/internal/filterapi/mcpconfig.go index 36bce59c0..89b8459c9 100644 --- a/internal/filterapi/mcpconfig.go +++ b/internal/filterapi/mcpconfig.go @@ -64,6 +64,9 @@ type MCPRouteName = string // MCPRouteAuthorization defines the authorization configuration for a MCPRoute. type MCPRouteAuthorization struct { + // DefaultAction is the action to take when no rules match. + DefaultAction AuthorizationAction `json:"defaultAction"` + // 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. @@ -75,14 +78,28 @@ type MCPRouteAuthorization struct { ResourceMetadataURL string `json:"resourceMetadataURL,omitempty"` } +type AuthorizationAction string + +const ( + // AuthorizationActionAllow is the action to allow the request. + AuthorizationActionAllow AuthorizationAction = "Allow" + // AuthorizationActionDeny is the action to deny the request. + AuthorizationActionDeny AuthorizationAction = "Deny" +) + // 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 { + // Action is the action to take when the rule matches. + Action AuthorizationAction `json:"action"` + // Source defines the authorization source for this rule. - Source MCPAuthorizationSource `json:"source"` + // If not specified, the rule will match all sources. + Source *MCPAuthorizationSource `json:"source,omitempty"` // Target defines the authorization target for this rule. - Target MCPAuthorizationTarget `json:"target"` + // If not specified, the rule will match all targets. + Target *MCPAuthorizationTarget `json:"target,omitempty"` } type MCPAuthorizationTarget struct { @@ -92,7 +109,7 @@ type MCPAuthorizationTarget struct { type MCPAuthorizationSource struct { // JWT defines the JWT scopes required for this rule to match. - JWT JWTSource `json:"jwt,omitempty"` + JWT JWTSource `json:"jwt"` } type JWTSource struct { diff --git a/internal/mcpproxy/authorization.go b/internal/mcpproxy/authorization.go index 8ab425d4e..f2db2be24 100644 --- a/internal/mcpproxy/authorization.go +++ b/internal/mcpproxy/authorization.go @@ -23,11 +23,13 @@ import ( type compiledAuthorization struct { ResourceMetadataURL string Rules []compiledAuthorizationRule + DefaultAction filterapi.AuthorizationAction } type compiledAuthorizationRule struct { - Source filterapi.MCPAuthorizationSource + Source *filterapi.MCPAuthorizationSource Target []compiledToolCall + Action filterapi.AuthorizationAction } type compiledToolCall struct { @@ -53,31 +55,35 @@ func compileAuthorization(auth *filterapi.MCPRouteAuthorization) (*compiledAutho compiled := &compiledAuthorization{ ResourceMetadataURL: auth.ResourceMetadataURL, + DefaultAction: auth.DefaultAction, } for _, rule := range auth.Rules { cr := compiledAuthorizationRule{ Source: rule.Source, + Action: rule.Action, } - for _, tool := range rule.Target.Tools { - ct := compiledToolCall{ - Backend: tool.Backend, - 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.Backend, tool.ToolName, issues.Err()) + if rule.Target != nil { + for _, tool := range rule.Target.Tools { + ct := compiledToolCall{ + Backend: tool.Backend, + ToolName: tool.ToolName, } - 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.Backend, tool.ToolName, err) + 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.Backend, 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.Backend, tool.ToolName, err) + } + ct.Expression = expr + ct.program = program } - ct.Expression = expr - ct.program = program + cr.Target = append(cr.Target, ct) } - cr.Target = append(cr.Target, ct) } compiled.Rules = append(compiled.Rules, cr) } @@ -91,47 +97,57 @@ func (m *MCPProxy) authorizeRequest(authorization *compiledAuthorization, header return true, nil } - // If no rules are defined, deny all requests. + defaultAction := authorization.DefaultAction == filterapi.AuthorizationActionAllow + + // If no rules are defined, return the default action. if len(authorization.Rules) == 0 { - return false, nil + return defaultAction, nil } - // If the rules are defined, a valid bearer token is required. + scopeSet := sets.New[string]() 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 + } else { + 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())) + } + scopeSet = sets.New(extractScopes(claims)...) } - scopeSet := sets.New(extractScopes(claims)...) var requiredScopesForChallenge []string for _, rule := range authorization.Rules { + action := rule.Action == filterapi.AuthorizationActionAllow + if !m.toolMatches(backendName, toolName, rule.Target, arguments) { continue } + // If no source is specified, the rule matches all sources. + if rule.Source == nil { + return action, nil + } + + // Scopes check doesn't make much sense if action is deny, we check it anyway. requiredScopes := rule.Source.JWT.Scopes if scopesSatisfied(scopeSet, requiredScopes) { - return true, nil + return action, nil } - // Keep track of the smallest set of required scopes for challenge. - if len(requiredScopesForChallenge) == 0 || len(requiredScopes) < len(requiredScopesForChallenge) { - requiredScopesForChallenge = requiredScopes + // Keep track of the smallest set of required scopes for challenge when the action is allow and the request is denied. + if action { + if len(requiredScopesForChallenge) == 0 || len(requiredScopes) < len(requiredScopesForChallenge) { + requiredScopesForChallenge = requiredScopes + } } } - return false, requiredScopesForChallenge + return defaultAction, requiredScopesForChallenge } func bearerToken(header string) (string, error) { @@ -176,6 +192,7 @@ func extractScopes(claims jwt.MapClaims) []string { } func (m *MCPProxy) toolMatches(backendName, toolName string, tools []compiledToolCall, args any) bool { + // Empty tools means all tools match. if len(tools) == 0 { return true } diff --git a/internal/mcpproxy/authorization_test.go b/internal/mcpproxy/authorization_test.go index 7c978d153..574c3f3da 100644 --- a/internal/mcpproxy/authorization_test.go +++ b/internal/mcpproxy/authorization_test.go @@ -47,12 +47,14 @@ func TestAuthorizeRequest(t *testing.T) { { name: "matching tool and scope", auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: "Deny", Rules: []filterapi.MCPRouteAuthorizationRule{ { - Source: filterapi.MCPAuthorizationSource{ + Action: "Allow", + Source: &filterapi.MCPAuthorizationSource{ JWT: filterapi.JWTSource{Scopes: []string{"read", "write"}}, }, - Target: filterapi.MCPAuthorizationTarget{ + Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}, }, }, @@ -67,12 +69,14 @@ func TestAuthorizeRequest(t *testing.T) { { name: "matching tool scope and arguments CEL", auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: "Deny", Rules: []filterapi.MCPRouteAuthorizationRule{ { - Source: filterapi.MCPAuthorizationSource{ + Action: "Allow", + Source: &filterapi.MCPAuthorizationSource{ JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, - Target: filterapi.MCPAuthorizationTarget{ + Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ Backend: "backend1", ToolName: "tool1", @@ -96,12 +100,14 @@ func TestAuthorizeRequest(t *testing.T) { { name: "numeric argument matches via CEL", auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: "Deny", Rules: []filterapi.MCPRouteAuthorizationRule{ { - Source: filterapi.MCPAuthorizationSource{ + Action: "Allow", + Source: &filterapi.MCPAuthorizationSource{ JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, - Target: filterapi.MCPAuthorizationTarget{ + Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ Backend: "backend1", ToolName: "tool1", @@ -121,12 +127,14 @@ func TestAuthorizeRequest(t *testing.T) { { name: "object argument can be matched via CEL safe navigation", auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: "Deny", Rules: []filterapi.MCPRouteAuthorizationRule{ { - Source: filterapi.MCPAuthorizationSource{ + Action: "Allow", + Source: &filterapi.MCPAuthorizationSource{ JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, - Target: filterapi.MCPAuthorizationTarget{ + Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ Backend: "backend1", ToolName: "tool1", @@ -151,12 +159,14 @@ func TestAuthorizeRequest(t *testing.T) { { name: "matching tool but insufficient scopes not allowed", auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: "Deny", Rules: []filterapi.MCPRouteAuthorizationRule{ { - Source: filterapi.MCPAuthorizationSource{ + Action: "Allow", + Source: &filterapi.MCPAuthorizationSource{ JWT: filterapi.JWTSource{Scopes: []string{"read", "write"}}, }, - Target: filterapi.MCPAuthorizationTarget{ + Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}, }, }, @@ -171,12 +181,14 @@ func TestAuthorizeRequest(t *testing.T) { { name: "arguments CEL mismatch denied", auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: "Deny", Rules: []filterapi.MCPRouteAuthorizationRule{ { - Source: filterapi.MCPAuthorizationSource{ + Action: "Allow", + Source: &filterapi.MCPAuthorizationSource{ JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, - Target: filterapi.MCPAuthorizationTarget{ + Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ Backend: "backend1", ToolName: "tool1", @@ -198,12 +210,14 @@ func TestAuthorizeRequest(t *testing.T) { { name: "arguments CEL failed evaluation denies", auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: "Deny", Rules: []filterapi.MCPRouteAuthorizationRule{ { - Source: filterapi.MCPAuthorizationSource{ + Action: "Allow", + Source: &filterapi.MCPAuthorizationSource{ JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, - Target: filterapi.MCPAuthorizationTarget{ + Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ Backend: "backend1", ToolName: "tool1", @@ -225,12 +239,14 @@ func TestAuthorizeRequest(t *testing.T) { { name: "arguments CEL returns non-boolean denies", auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: "Deny", Rules: []filterapi.MCPRouteAuthorizationRule{ { - Source: filterapi.MCPAuthorizationSource{ + Action: "Allow", + Source: &filterapi.MCPAuthorizationSource{ JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, - Target: filterapi.MCPAuthorizationTarget{ + Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ Backend: "backend1", ToolName: "tool1", @@ -252,12 +268,14 @@ func TestAuthorizeRequest(t *testing.T) { { name: "arguments invalid CEL denies", auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: "Deny", Rules: []filterapi.MCPRouteAuthorizationRule{ { - Source: filterapi.MCPAuthorizationSource{ + Action: "Allow", + Source: &filterapi.MCPAuthorizationSource{ JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, - Target: filterapi.MCPAuthorizationTarget{ + Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ Backend: "backend1", ToolName: "tool1", @@ -280,12 +298,14 @@ func TestAuthorizeRequest(t *testing.T) { { name: "missing argument denies when required", auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: "Deny", Rules: []filterapi.MCPRouteAuthorizationRule{ { - Source: filterapi.MCPAuthorizationSource{ + Action: "Allow", + Source: &filterapi.MCPAuthorizationSource{ JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, - Target: filterapi.MCPAuthorizationTarget{ + Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ Backend: "backend1", ToolName: "tool1", @@ -305,12 +325,14 @@ func TestAuthorizeRequest(t *testing.T) { { name: "no matching rule falls back to default deny - tool mismatch", auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: "Deny", Rules: []filterapi.MCPRouteAuthorizationRule{ { - Source: filterapi.MCPAuthorizationSource{ + Action: "Allow", + Source: &filterapi.MCPAuthorizationSource{ JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, - Target: filterapi.MCPAuthorizationTarget{ + Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}, }, }, @@ -325,12 +347,14 @@ func TestAuthorizeRequest(t *testing.T) { { name: "no matching rule falls back to default deny - scope mismatch", auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: "Deny", Rules: []filterapi.MCPRouteAuthorizationRule{ { - Source: filterapi.MCPAuthorizationSource{ + Action: "Allow", + Source: &filterapi.MCPAuthorizationSource{ JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, - Target: filterapi.MCPAuthorizationTarget{ + Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}, }, }, @@ -342,24 +366,17 @@ func TestAuthorizeRequest(t *testing.T) { expectAllowed: false, expectScopes: nil, }, - { - name: "no rules falls back to default deny", - auth: &filterapi.MCPRouteAuthorization{}, - header: "", - backend: "backend1", - toolName: "tool1", - expectAllowed: false, - expectScopes: nil, - }, { name: "no bearer token not allowed when rules exist", auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: "Deny", Rules: []filterapi.MCPRouteAuthorizationRule{ { - Source: filterapi.MCPAuthorizationSource{ + Action: "Allow", + Source: &filterapi.MCPAuthorizationSource{ JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, - Target: filterapi.MCPAuthorizationTarget{ + Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}, }, }, @@ -369,17 +386,19 @@ func TestAuthorizeRequest(t *testing.T) { backend: "backend1", toolName: "tool1", expectAllowed: false, - expectScopes: nil, + expectScopes: []string{"read"}, }, { name: "invalid bearer token not allowed when rules exist", auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: "Deny", Rules: []filterapi.MCPRouteAuthorizationRule{ { - Source: filterapi.MCPAuthorizationSource{ + Action: "Allow", + Source: &filterapi.MCPAuthorizationSource{ JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, - Target: filterapi.MCPAuthorizationTarget{ + Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}, }, }, @@ -389,19 +408,22 @@ func TestAuthorizeRequest(t *testing.T) { backend: "backend1", toolName: "tool1", expectAllowed: false, - expectScopes: nil, + expectScopes: []string{"read"}, }, { name: "selects smallest required scope set when multiple rules match", auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: "Deny", Rules: []filterapi.MCPRouteAuthorizationRule{ { - Source: filterapi.MCPAuthorizationSource{JWT: filterapi.JWTSource{Scopes: []string{"alpha", "beta", "gamma"}}}, - Target: filterapi.MCPAuthorizationTarget{Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}}, + Action: "Allow", + Source: &filterapi.MCPAuthorizationSource{JWT: filterapi.JWTSource{Scopes: []string{"alpha", "beta", "gamma"}}}, + Target: &filterapi.MCPAuthorizationTarget{Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}}, }, { - Source: filterapi.MCPAuthorizationSource{JWT: filterapi.JWTSource{Scopes: []string{"alpha", "beta"}}}, - Target: filterapi.MCPAuthorizationTarget{Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}}, + Action: "Allow", + Source: &filterapi.MCPAuthorizationSource{JWT: filterapi.JWTSource{Scopes: []string{"alpha", "beta"}}}, + Target: &filterapi.MCPAuthorizationTarget{Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}}, }, }, }, @@ -411,6 +433,136 @@ func TestAuthorizeRequest(t *testing.T) { expectAllowed: false, expectScopes: []string{"alpha", "beta"}, }, + { + name: "allow requests with required scopes except those matching CEL deny rule - deny request", + auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: "Deny", + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Action: "Deny", + Target: &filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{ + Backend: "backend1", + ToolName: "listFiles", + Arguments: strPtr(`args.folder == "restricted"`), + }}, + }, + }, + { + Action: "Allow", + Source: &filterapi.MCPAuthorizationSource{ + JWT: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: &filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{ + Backend: "backend1", + ToolName: "listFiles", + }}, + }, + }, + }, + }, + header: "Bearer " + makeToken("read"), + backend: "backend1", + toolName: "listFiles", + args: map[string]any{ + "folder": "restricted", + }, + expectAllowed: false, + expectScopes: nil, + }, + { + name: "allow requests with required scopes except those matching CEL deny rule - allow request", + auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: "Deny", + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Action: "Deny", + Target: &filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{ + Backend: "backend1", + ToolName: "listFiles", + Arguments: strPtr(`args.folder == "restricted"`), + }}, + }, + }, + { + Action: "Allow", + Source: &filterapi.MCPAuthorizationSource{ + JWT: filterapi.JWTSource{Scopes: []string{"read"}}, + }, + Target: &filterapi.MCPAuthorizationTarget{ + Tools: []filterapi.ToolCall{{ + Backend: "backend1", + ToolName: "listFiles", + }}, + }, + }, + }, + }, + header: "Bearer " + makeToken("read"), + backend: "backend1", + toolName: "listFiles", + args: map[string]any{ + "folder": "allowed", + }, + expectAllowed: true, + expectScopes: nil, + }, + { + name: "no rules default deny", + auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: "Deny", + }, + header: "", + backend: "backend1", + toolName: "tool1", + expectAllowed: false, + expectScopes: nil, + }, + { + name: "no rules default allow", + auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: "Allow", + }, + header: "", + backend: "backend1", + toolName: "tool1", + expectAllowed: true, + expectScopes: nil, + }, + { + name: "empty rule default deny", + auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: "Deny", + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Action: "Allow", + }, + }, + }, + header: "", + backend: "backend1", + toolName: "tool1", + expectAllowed: true, + expectScopes: nil, + }, + { + name: "empty rule default allow", + auth: &filterapi.MCPRouteAuthorization{ + DefaultAction: "Allow", + Rules: []filterapi.MCPRouteAuthorizationRule{ + { + Action: "Deny", + }, + }, + }, + header: "", + backend: "backend1", + toolName: "tool1", + expectAllowed: false, + expectScopes: nil, + }, } for _, tt := range tests { @@ -453,10 +605,10 @@ func TestCompileAuthorizationInvalidExpression(t *testing.T) { _, err := compileAuthorization(&filterapi.MCPRouteAuthorization{ Rules: []filterapi.MCPRouteAuthorizationRule{ { - Source: filterapi.MCPAuthorizationSource{ + Source: &filterapi.MCPAuthorizationSource{ JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, - Target: filterapi.MCPAuthorizationTarget{ + Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ Backend: "backend1", ToolName: "tool1", 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 6e424867e..38b606238 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 @@ -585,21 +585,38 @@ spec: description: Authorization defines the configuration for the MCP spec compatible authorization. properties: + defaultAction: + default: Deny + description: DefaultAction is the action to take when no rules + match. If unspecified, defaults to Deny. + enum: + - Allow + - Deny + type: string rules: description: |- Rules defines a list of authorization rules. + These rules are evaluated in order, the first matching rule will be applied, + and the rest will be skipped. - 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. + If no rules are defined, the default action will be applied to all requests. 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: + action: + default: Allow + description: Action is the authorization decision for + matching requests. If unspecified, defaults to Allow. + enum: + - Allow + - Deny + type: string source: - description: Source defines the authorization source - for this rule. + description: |- + Source defines the authorization source for this rule. + If not specified, the rule will match all sources. properties: jwt: description: JWT defines the JWT scopes required @@ -623,8 +640,9 @@ spec: - jwt type: object target: - description: Target defines the authorization target - for this rule. + description: |- + Target defines the authorization target for this rule. + If not specified, the rule will match all targets. properties: tools: description: Tools defines the list of tools this @@ -657,9 +675,6 @@ spec: required: - tools type: object - required: - - source - - target type: object type: array type: object diff --git a/site/docs/api/api.mdx b/site/docs/api/api.mdx index 1ebf513d4..c7668ddcd 100644 --- a/site/docs/api/api.mdx +++ b/site/docs/api/api.mdx @@ -1648,10 +1648,16 @@ MCPRouteAuthorization defines the authorization configuration for a MCPRoute. @@ -1673,12 +1679,18 @@ Reference: https://modelcontextprotocol.io/specification/draft/basic/authorizati name="source" type="[MCPAuthorizationSource](#mcpauthorizationsource)" required="true" - description="Source defines the authorization source for this rule." + description="Source defines the authorization source for this rule.
If not specified, the rule will match all sources." /> From dab18f88136ac314e32a93710bd9334a36155d88 Mon Sep 17 00:00:00 2001 From: Huabing Zhao Date: Thu, 4 Dec 2025 15:45:56 +0800 Subject: [PATCH 20/21] rename arguments to condition Signed-off-by: Huabing Zhao --- api/v1alpha1/mcp_route.go | 4 ++-- api/v1alpha1/zz_generated.deepcopy.go | 4 ++-- internal/controller/gateway.go | 2 +- internal/filterapi/mcpconfig.go | 4 ++-- internal/mcpproxy/authorization.go | 12 +++++----- internal/mcpproxy/authorization_test.go | 22 +++++++++---------- .../aigateway.envoyproxy.io_mcproutes.yaml | 12 +++++----- site/docs/api/api.mdx | 4 ++-- .../e2e/testdata/mcp_route_authorization.yaml | 2 +- 9 files changed, 33 insertions(+), 33 deletions(-) diff --git a/api/v1alpha1/mcp_route.go b/api/v1alpha1/mcp_route.go index ab07af6ee..8e47a5455 100644 --- a/api/v1alpha1/mcp_route.go +++ b/api/v1alpha1/mcp_route.go @@ -321,14 +321,14 @@ type ToolCall struct { // +kubebuilder:validation:Required ToolName string `json:"toolName"` - // Arguments is a CEL expression that must evaluate to true for the rule to match. + // Condition 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"` + Condition *string `json:"condition,omitempty"` } // JWKS defines how to obtain JSON Web Key Sets (JWKS) either from a remote HTTP/HTTPS endpoint or from a local source. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 03e8dee58..f0e0b23e8 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -1387,8 +1387,8 @@ func (in *ProtectedResourceMetadata) DeepCopy() *ProtectedResourceMetadata { // 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 + if in.Condition != nil { + in, out := &in.Condition, &out.Condition *out = new(string) **out = **in } diff --git a/internal/controller/gateway.go b/internal/controller/gateway.go index 4ec6d5ff8..2f6e318be 100644 --- a/internal/controller/gateway.go +++ b/internal/controller/gateway.go @@ -495,7 +495,7 @@ func mcpConfig(mcpRoutes []aigv1a1.MCPRoute) *filterapi.MCPConfig { tools[i] = filterapi.ToolCall{ Backend: tool.Backend, ToolName: tool.ToolName, - Arguments: tool.Arguments, + Condition: tool.Condition, } } diff --git a/internal/filterapi/mcpconfig.go b/internal/filterapi/mcpconfig.go index 89b8459c9..90d9ad59e 100644 --- a/internal/filterapi/mcpconfig.go +++ b/internal/filterapi/mcpconfig.go @@ -125,7 +125,7 @@ type ToolCall struct { // ToolName is the name of the tool. ToolName string `json:"toolName"` - // Arguments is a CEL expression evaluated against the tool call arguments map. + // Condition 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"` + Condition *string `json:"condition,omitempty"` } diff --git a/internal/mcpproxy/authorization.go b/internal/mcpproxy/authorization.go index f2db2be24..13b8ae377 100644 --- a/internal/mcpproxy/authorization.go +++ b/internal/mcpproxy/authorization.go @@ -69,15 +69,15 @@ func compileAuthorization(auth *filterapi.MCPRouteAuthorization) (*compiledAutho Backend: tool.Backend, ToolName: tool.ToolName, } - if tool.Arguments != nil && strings.TrimSpace(*tool.Arguments) != "" { - expr := strings.TrimSpace(*tool.Arguments) + if tool.Condition != nil && strings.TrimSpace(*tool.Condition) != "" { + expr := strings.TrimSpace(*tool.Condition) 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.Backend, tool.ToolName, issues.Err()) + return nil, fmt.Errorf("failed to compile condition CEL for tool %s/%s: %w", tool.Backend, 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.Backend, tool.ToolName, err) + return nil, fmt.Errorf("failed to build condition CEL program for tool %s/%s: %w", tool.Backend, tool.ToolName, err) } ct.Expression = expr ct.program = program @@ -207,7 +207,7 @@ func (m *MCPProxy) toolMatches(backendName, toolName string, tools []compiledToo 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.Backend), slog.String("tool", t.ToolName), slog.String("error", err.Error())) + m.l.Error("failed to evaluate condition CEL", slog.String("backend", t.Backend), slog.String("tool", t.ToolName), slog.String("error", err.Error())) continue } @@ -221,7 +221,7 @@ func (m *MCPProxy) toolMatches(backendName, toolName string, tools []compiledToo return true } default: - m.l.Error("arguments CEL did not return a boolean", slog.String("backend", t.Backend), slog.String("tool", t.ToolName), slog.String("expression", t.Expression)) + m.l.Error("condition CEL did not return a boolean", slog.String("backend", t.Backend), slog.String("tool", t.ToolName), slog.String("expression", t.Expression)) } } // If no matching tool entry or no arguments matched, fail. diff --git a/internal/mcpproxy/authorization_test.go b/internal/mcpproxy/authorization_test.go index 574c3f3da..b8a1b55ef 100644 --- a/internal/mcpproxy/authorization_test.go +++ b/internal/mcpproxy/authorization_test.go @@ -80,7 +80,7 @@ func TestAuthorizeRequest(t *testing.T) { Tools: []filterapi.ToolCall{{ Backend: "backend1", ToolName: "tool1", - Arguments: strPtr(`args.mode in ["fast", "slow"] && args.user.matches("u-[0-9]+") && args.debug == true`), + Condition: strPtr(`args.mode in ["fast", "slow"] && args.user.matches("u-[0-9]+") && args.debug == true`), }}, }, }, @@ -111,7 +111,7 @@ func TestAuthorizeRequest(t *testing.T) { Tools: []filterapi.ToolCall{{ Backend: "backend1", ToolName: "tool1", - Arguments: strPtr(`int(args.count) >= 40 && int(args.count) < 50`), + Condition: strPtr(`int(args.count) >= 40 && int(args.count) < 50`), }}, }, }, @@ -138,7 +138,7 @@ func TestAuthorizeRequest(t *testing.T) { Tools: []filterapi.ToolCall{{ Backend: "backend1", ToolName: "tool1", - Arguments: strPtr(`args["payload"] != null && args["payload"]["kind"] == "test" && args["payload"]["value"] == 123`), + Condition: strPtr(`args["payload"] != null && args["payload"]["kind"] == "test" && args["payload"]["value"] == 123`), }}, }, }, @@ -192,7 +192,7 @@ func TestAuthorizeRequest(t *testing.T) { Tools: []filterapi.ToolCall{{ Backend: "backend1", ToolName: "tool1", - Arguments: strPtr(`args.mode in ["fast", "slow"]`), + Condition: strPtr(`args.mode in ["fast", "slow"]`), }}, }, }, @@ -221,7 +221,7 @@ func TestAuthorizeRequest(t *testing.T) { Tools: []filterapi.ToolCall{{ Backend: "backend1", ToolName: "tool1", - Arguments: strPtr(`args.nonExistingField in ["fast", "slow"]`), + Condition: strPtr(`args.nonExistingField in ["fast", "slow"]`), }}, }, }, @@ -250,7 +250,7 @@ func TestAuthorizeRequest(t *testing.T) { Tools: []filterapi.ToolCall{{ Backend: "backend1", ToolName: "tool1", - Arguments: strPtr(`args.mode`), + Condition: strPtr(`args.mode`), }}, }, }, @@ -279,7 +279,7 @@ func TestAuthorizeRequest(t *testing.T) { Tools: []filterapi.ToolCall{{ Backend: "backend1", ToolName: "tool1", - Arguments: strPtr(`invalid syntax here`), + Condition: strPtr(`invalid syntax here`), }}, }, }, @@ -309,7 +309,7 @@ func TestAuthorizeRequest(t *testing.T) { Tools: []filterapi.ToolCall{{ Backend: "backend1", ToolName: "tool1", - Arguments: strPtr(`args["mode"] == "fast"`), + Condition: strPtr(`args["mode"] == "fast"`), }}, }, }, @@ -444,7 +444,7 @@ func TestAuthorizeRequest(t *testing.T) { Tools: []filterapi.ToolCall{{ Backend: "backend1", ToolName: "listFiles", - Arguments: strPtr(`args.folder == "restricted"`), + Condition: strPtr(`args.folder == "restricted"`), }}, }, }, @@ -482,7 +482,7 @@ func TestAuthorizeRequest(t *testing.T) { Tools: []filterapi.ToolCall{{ Backend: "backend1", ToolName: "listFiles", - Arguments: strPtr(`args.folder == "restricted"`), + Condition: strPtr(`args.folder == "restricted"`), }}, }, }, @@ -612,7 +612,7 @@ func TestCompileAuthorizationInvalidExpression(t *testing.T) { Tools: []filterapi.ToolCall{{ Backend: "backend1", ToolName: "tool1", - Arguments: strPtr("args."), + Condition: strPtr("args."), }}, }, }, 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 38b606238..16613d61a 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 @@ -651,17 +651,17 @@ spec: description: ToolCall represents a tool call in the MCP authorization target. properties: - arguments: + backend: + description: Backend is the name of the backend + this tool belongs to. + type: string + condition: description: |- - Arguments is a CEL expression that must evaluate to true for the rule to match. + Condition 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 - backend: - description: Backend is the name of the backend - this tool belongs to. - type: string toolName: description: ToolName is the name of the tool. type: string diff --git a/site/docs/api/api.mdx b/site/docs/api/api.mdx index c7668ddcd..99b080d32 100644 --- a/site/docs/api/api.mdx +++ b/site/docs/api/api.mdx @@ -1988,10 +1988,10 @@ ToolCall represents a tool call in the MCP authorization target. required="true" description="ToolName is the name of the tool." /> diff --git a/tests/e2e/testdata/mcp_route_authorization.yaml b/tests/e2e/testdata/mcp_route_authorization.yaml index c31c8113f..76f9e99cf 100644 --- a/tests/e2e/testdata/mcp_route_authorization.yaml +++ b/tests/e2e/testdata/mcp_route_authorization.yaml @@ -68,7 +68,7 @@ spec: tools: - backend: mcp-backend-authorization toolName: echo - arguments: args.text.matches("^Hello, .*!$") + condition: args.text.matches("^Hello, .*!$") - source: jwt: scopes: From 9271f0b6e935964e06ef899b27824807d8170f34 Mon Sep 17 00:00:00 2001 From: Huabing Zhao Date: Thu, 4 Dec 2025 16:21:14 +0800 Subject: [PATCH 21/21] rename ToolName to Tool Signed-off-by: Huabing Zhao --- api/v1alpha1/mcp_route.go | 4 +- internal/controller/gateway.go | 2 +- internal/filterapi/mcpconfig.go | 4 +- internal/mcpproxy/authorization.go | 22 ++-- internal/mcpproxy/authorization_test.go | 124 +++++++++--------- .../aigateway.envoyproxy.io_mcproutes.yaml | 6 +- site/docs/api/api.mdx | 4 +- .../authorization_without_oauth.yaml | 2 +- .../e2e/testdata/mcp_route_authorization.yaml | 4 +- 9 files changed, 86 insertions(+), 86 deletions(-) diff --git a/api/v1alpha1/mcp_route.go b/api/v1alpha1/mcp_route.go index 8e47a5455..185626604 100644 --- a/api/v1alpha1/mcp_route.go +++ b/api/v1alpha1/mcp_route.go @@ -316,10 +316,10 @@ type ToolCall struct { // +kubebuilder:validation:Required Backend string `json:"backend"` - // ToolName is the name of the tool. + // Tool is the name of the tool. // // +kubebuilder:validation:Required - ToolName string `json:"toolName"` + Tool string `json:"tool"` // Condition 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. diff --git a/internal/controller/gateway.go b/internal/controller/gateway.go index 2f6e318be..a68f60ec2 100644 --- a/internal/controller/gateway.go +++ b/internal/controller/gateway.go @@ -494,7 +494,7 @@ func mcpConfig(mcpRoutes []aigv1a1.MCPRoute) *filterapi.MCPConfig { for i, tool := range rule.Target.Tools { tools[i] = filterapi.ToolCall{ Backend: tool.Backend, - ToolName: tool.ToolName, + Tool: tool.Tool, Condition: tool.Condition, } } diff --git a/internal/filterapi/mcpconfig.go b/internal/filterapi/mcpconfig.go index 90d9ad59e..80d266dbb 100644 --- a/internal/filterapi/mcpconfig.go +++ b/internal/filterapi/mcpconfig.go @@ -122,8 +122,8 @@ type ToolCall struct { // Backend is the name of the backend this tool belongs to. Backend string `json:"backend"` - // ToolName is the name of the tool. - ToolName string `json:"toolName"` + // Tool is the name of the tool. + Tool string `json:"tool"` // Condition is a CEL expression evaluated against the tool call arguments map. // The expression must evaluate to true for the rule to apply. diff --git a/internal/mcpproxy/authorization.go b/internal/mcpproxy/authorization.go index 13b8ae377..e19b0714d 100644 --- a/internal/mcpproxy/authorization.go +++ b/internal/mcpproxy/authorization.go @@ -34,7 +34,7 @@ type compiledAuthorizationRule struct { type compiledToolCall struct { Backend string - ToolName string + Tool string Expression string program cel.Program } @@ -66,18 +66,18 @@ func compileAuthorization(auth *filterapi.MCPRouteAuthorization) (*compiledAutho if rule.Target != nil { for _, tool := range rule.Target.Tools { ct := compiledToolCall{ - Backend: tool.Backend, - ToolName: tool.ToolName, + Backend: tool.Backend, + Tool: tool.Tool, } if tool.Condition != nil && strings.TrimSpace(*tool.Condition) != "" { expr := strings.TrimSpace(*tool.Condition) ast, issues := env.Compile(expr) if issues != nil && issues.Err() != nil { - return nil, fmt.Errorf("failed to compile condition CEL for tool %s/%s: %w", tool.Backend, tool.ToolName, issues.Err()) + return nil, fmt.Errorf("failed to compile condition CEL for tool %s/%s: %w", tool.Backend, tool.Tool, issues.Err()) } program, err := env.Program(ast, cel.CostLimit(10000), cel.EvalOptions(cel.OptOptimize)) if err != nil { - return nil, fmt.Errorf("failed to build condition CEL program for tool %s/%s: %w", tool.Backend, tool.ToolName, err) + return nil, fmt.Errorf("failed to build condition CEL program for tool %s/%s: %w", tool.Backend, tool.Tool, err) } ct.Expression = expr ct.program = program @@ -92,7 +92,7 @@ func compileAuthorization(auth *filterapi.MCPRouteAuthorization) (*compiledAutho } // 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) { +func (m *MCPProxy) authorizeRequest(authorization *compiledAuthorization, headers http.Header, backend, tool string, arguments any) (bool, []string) { if authorization == nil { return true, nil } @@ -124,7 +124,7 @@ func (m *MCPProxy) authorizeRequest(authorization *compiledAuthorization, header for _, rule := range authorization.Rules { action := rule.Action == filterapi.AuthorizationActionAllow - if !m.toolMatches(backendName, toolName, rule.Target, arguments) { + if !m.toolMatches(backend, tool, rule.Target, arguments) { continue } @@ -191,14 +191,14 @@ func extractScopes(claims jwt.MapClaims) []string { } } -func (m *MCPProxy) toolMatches(backendName, toolName string, tools []compiledToolCall, args any) bool { +func (m *MCPProxy) toolMatches(backend, tool string, tools []compiledToolCall, args any) bool { // Empty tools means all tools match. if len(tools) == 0 { return true } for _, t := range tools { - if t.Backend != backendName || t.ToolName != toolName { + if t.Backend != backend || t.Tool != tool { continue } if t.program == nil { @@ -207,7 +207,7 @@ func (m *MCPProxy) toolMatches(backendName, toolName string, tools []compiledToo result, _, err := t.program.Eval(map[string]any{"args": args}) if err != nil { - m.l.Error("failed to evaluate condition CEL", slog.String("backend", t.Backend), slog.String("tool", t.ToolName), slog.String("error", err.Error())) + m.l.Error("failed to evaluate condition CEL", slog.String("backend", t.Backend), slog.String("tool", t.Tool), slog.String("error", err.Error())) continue } @@ -221,7 +221,7 @@ func (m *MCPProxy) toolMatches(backendName, toolName string, tools []compiledToo return true } default: - m.l.Error("condition CEL did not return a boolean", slog.String("backend", t.Backend), slog.String("tool", t.ToolName), slog.String("expression", t.Expression)) + m.l.Error("condition CEL did not return a boolean", slog.String("backend", t.Backend), slog.String("tool", t.Tool), slog.String("expression", t.Expression)) } } // If no matching tool entry or no arguments matched, fail. diff --git a/internal/mcpproxy/authorization_test.go b/internal/mcpproxy/authorization_test.go index b8a1b55ef..a408a25a5 100644 --- a/internal/mcpproxy/authorization_test.go +++ b/internal/mcpproxy/authorization_test.go @@ -38,7 +38,7 @@ func TestAuthorizeRequest(t *testing.T) { auth *filterapi.MCPRouteAuthorization header string backend string - toolName string + tool string args map[string]any expectError bool expectAllowed bool @@ -55,14 +55,14 @@ func TestAuthorizeRequest(t *testing.T) { JWT: filterapi.JWTSource{Scopes: []string{"read", "write"}}, }, Target: &filterapi.MCPAuthorizationTarget{ - Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}, + Tools: []filterapi.ToolCall{{Backend: "backend1", Tool: "tool1"}}, }, }, }, }, header: "Bearer " + makeToken("read", "write"), backend: "backend1", - toolName: "tool1", + tool: "tool1", expectAllowed: true, expectScopes: nil, }, @@ -79,16 +79,16 @@ func TestAuthorizeRequest(t *testing.T) { Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ Backend: "backend1", - ToolName: "tool1", + Tool: "tool1", Condition: strPtr(`args.mode in ["fast", "slow"] && args.user.matches("u-[0-9]+") && args.debug == true`), }}, }, }, }, }, - header: "Bearer " + makeToken("read"), - backend: "backend1", - toolName: "tool1", + header: "Bearer " + makeToken("read"), + backend: "backend1", + tool: "tool1", args: map[string]any{ "mode": "fast", "user": "u-123", @@ -110,7 +110,7 @@ func TestAuthorizeRequest(t *testing.T) { Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ Backend: "backend1", - ToolName: "tool1", + Tool: "tool1", Condition: strPtr(`int(args.count) >= 40 && int(args.count) < 50`), }}, }, @@ -119,7 +119,7 @@ func TestAuthorizeRequest(t *testing.T) { }, header: "Bearer " + makeToken("read"), backend: "backend1", - toolName: "tool1", + tool: "tool1", args: map[string]any{"count": 42}, expectAllowed: true, expectScopes: nil, @@ -137,16 +137,16 @@ func TestAuthorizeRequest(t *testing.T) { Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ Backend: "backend1", - ToolName: "tool1", + Tool: "tool1", Condition: strPtr(`args["payload"] != null && args["payload"]["kind"] == "test" && args["payload"]["value"] == 123`), }}, }, }, }, }, - header: "Bearer " + makeToken("read"), - backend: "backend1", - toolName: "tool1", + header: "Bearer " + makeToken("read"), + backend: "backend1", + tool: "tool1", args: map[string]any{ "payload": map[string]any{ "kind": "test", @@ -167,14 +167,14 @@ func TestAuthorizeRequest(t *testing.T) { JWT: filterapi.JWTSource{Scopes: []string{"read", "write"}}, }, Target: &filterapi.MCPAuthorizationTarget{ - Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}, + Tools: []filterapi.ToolCall{{Backend: "backend1", Tool: "tool1"}}, }, }, }, }, header: "Bearer " + makeToken("read"), backend: "backend1", - toolName: "tool1", + tool: "tool1", expectAllowed: false, expectScopes: []string{"read", "write"}, }, @@ -191,16 +191,16 @@ func TestAuthorizeRequest(t *testing.T) { Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ Backend: "backend1", - ToolName: "tool1", + Tool: "tool1", Condition: strPtr(`args.mode in ["fast", "slow"]`), }}, }, }, }, }, - header: "Bearer " + makeToken("read"), - backend: "backend1", - toolName: "tool1", + header: "Bearer " + makeToken("read"), + backend: "backend1", + tool: "tool1", args: map[string]any{ "mode": "other", }, @@ -220,16 +220,16 @@ func TestAuthorizeRequest(t *testing.T) { Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ Backend: "backend1", - ToolName: "tool1", + Tool: "tool1", Condition: strPtr(`args.nonExistingField in ["fast", "slow"]`), }}, }, }, }, }, - header: "Bearer " + makeToken("read"), - backend: "backend1", - toolName: "tool1", + header: "Bearer " + makeToken("read"), + backend: "backend1", + tool: "tool1", args: map[string]any{ "mode": "other", }, @@ -249,16 +249,16 @@ func TestAuthorizeRequest(t *testing.T) { Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ Backend: "backend1", - ToolName: "tool1", + Tool: "tool1", Condition: strPtr(`args.mode`), }}, }, }, }, }, - header: "Bearer " + makeToken("read"), - backend: "backend1", - toolName: "tool1", + header: "Bearer " + makeToken("read"), + backend: "backend1", + tool: "tool1", args: map[string]any{ "mode": "other", }, @@ -278,16 +278,16 @@ func TestAuthorizeRequest(t *testing.T) { Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ Backend: "backend1", - ToolName: "tool1", + Tool: "tool1", Condition: strPtr(`invalid syntax here`), }}, }, }, }, }, - header: "Bearer " + makeToken("read"), - backend: "backend1", - toolName: "tool1", + header: "Bearer " + makeToken("read"), + backend: "backend1", + tool: "tool1", args: map[string]any{ "mode": "other", }, @@ -308,7 +308,7 @@ func TestAuthorizeRequest(t *testing.T) { Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ Backend: "backend1", - ToolName: "tool1", + Tool: "tool1", Condition: strPtr(`args["mode"] == "fast"`), }}, }, @@ -317,7 +317,7 @@ func TestAuthorizeRequest(t *testing.T) { }, header: "Bearer " + makeToken("read"), backend: "backend1", - toolName: "tool1", + tool: "tool1", args: map[string]any{}, expectAllowed: false, expectScopes: nil, @@ -333,14 +333,14 @@ func TestAuthorizeRequest(t *testing.T) { JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, Target: &filterapi.MCPAuthorizationTarget{ - Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}, + Tools: []filterapi.ToolCall{{Backend: "backend1", Tool: "tool1"}}, }, }, }, }, header: "Bearer " + makeToken("read", "write"), backend: "backend1", - toolName: "other-tool", + tool: "other-tool", expectAllowed: false, expectScopes: nil, }, @@ -355,14 +355,14 @@ func TestAuthorizeRequest(t *testing.T) { JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, Target: &filterapi.MCPAuthorizationTarget{ - Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}, + Tools: []filterapi.ToolCall{{Backend: "backend1", Tool: "tool1"}}, }, }, }, }, header: "Bearer " + makeToken("foo", "bar"), backend: "backend1", - toolName: "other-tool", + tool: "other-tool", expectAllowed: false, expectScopes: nil, }, @@ -377,14 +377,14 @@ func TestAuthorizeRequest(t *testing.T) { JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, Target: &filterapi.MCPAuthorizationTarget{ - Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}, + Tools: []filterapi.ToolCall{{Backend: "backend1", Tool: "tool1"}}, }, }, }, }, header: "", backend: "backend1", - toolName: "tool1", + tool: "tool1", expectAllowed: false, expectScopes: []string{"read"}, }, @@ -399,14 +399,14 @@ func TestAuthorizeRequest(t *testing.T) { JWT: filterapi.JWTSource{Scopes: []string{"read"}}, }, Target: &filterapi.MCPAuthorizationTarget{ - Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}, + Tools: []filterapi.ToolCall{{Backend: "backend1", Tool: "tool1"}}, }, }, }, }, header: "Bearer invalid.token.here", backend: "backend1", - toolName: "tool1", + tool: "tool1", expectAllowed: false, expectScopes: []string{"read"}, }, @@ -418,18 +418,18 @@ func TestAuthorizeRequest(t *testing.T) { { Action: "Allow", Source: &filterapi.MCPAuthorizationSource{JWT: filterapi.JWTSource{Scopes: []string{"alpha", "beta", "gamma"}}}, - Target: &filterapi.MCPAuthorizationTarget{Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}}, + Target: &filterapi.MCPAuthorizationTarget{Tools: []filterapi.ToolCall{{Backend: "backend1", Tool: "tool1"}}}, }, { Action: "Allow", Source: &filterapi.MCPAuthorizationSource{JWT: filterapi.JWTSource{Scopes: []string{"alpha", "beta"}}}, - Target: &filterapi.MCPAuthorizationTarget{Tools: []filterapi.ToolCall{{Backend: "backend1", ToolName: "tool1"}}}, + Target: &filterapi.MCPAuthorizationTarget{Tools: []filterapi.ToolCall{{Backend: "backend1", Tool: "tool1"}}}, }, }, }, header: "Bearer " + makeToken("alpha"), backend: "backend1", - toolName: "tool1", + tool: "tool1", expectAllowed: false, expectScopes: []string{"alpha", "beta"}, }, @@ -443,7 +443,7 @@ func TestAuthorizeRequest(t *testing.T) { Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ Backend: "backend1", - ToolName: "listFiles", + Tool: "listFiles", Condition: strPtr(`args.folder == "restricted"`), }}, }, @@ -455,16 +455,16 @@ func TestAuthorizeRequest(t *testing.T) { }, Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ - Backend: "backend1", - ToolName: "listFiles", + Backend: "backend1", + Tool: "listFiles", }}, }, }, }, }, - header: "Bearer " + makeToken("read"), - backend: "backend1", - toolName: "listFiles", + header: "Bearer " + makeToken("read"), + backend: "backend1", + tool: "listFiles", args: map[string]any{ "folder": "restricted", }, @@ -481,7 +481,7 @@ func TestAuthorizeRequest(t *testing.T) { Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ Backend: "backend1", - ToolName: "listFiles", + Tool: "listFiles", Condition: strPtr(`args.folder == "restricted"`), }}, }, @@ -493,16 +493,16 @@ func TestAuthorizeRequest(t *testing.T) { }, Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ - Backend: "backend1", - ToolName: "listFiles", + Backend: "backend1", + Tool: "listFiles", }}, }, }, }, }, - header: "Bearer " + makeToken("read"), - backend: "backend1", - toolName: "listFiles", + header: "Bearer " + makeToken("read"), + backend: "backend1", + tool: "listFiles", args: map[string]any{ "folder": "allowed", }, @@ -516,7 +516,7 @@ func TestAuthorizeRequest(t *testing.T) { }, header: "", backend: "backend1", - toolName: "tool1", + tool: "tool1", expectAllowed: false, expectScopes: nil, }, @@ -527,7 +527,7 @@ func TestAuthorizeRequest(t *testing.T) { }, header: "", backend: "backend1", - toolName: "tool1", + tool: "tool1", expectAllowed: true, expectScopes: nil, }, @@ -543,7 +543,7 @@ func TestAuthorizeRequest(t *testing.T) { }, header: "", backend: "backend1", - toolName: "tool1", + tool: "tool1", expectAllowed: true, expectScopes: nil, }, @@ -559,7 +559,7 @@ func TestAuthorizeRequest(t *testing.T) { }, header: "", backend: "backend1", - toolName: "tool1", + tool: "tool1", expectAllowed: false, expectScopes: nil, }, @@ -578,7 +578,7 @@ func TestAuthorizeRequest(t *testing.T) { if err != nil { return } - allowed, requiredScopes := proxy.authorizeRequest(compiled, headers, tt.backend, tt.toolName, tt.args) + allowed, requiredScopes := proxy.authorizeRequest(compiled, headers, tt.backend, tt.tool, tt.args) if allowed != tt.expectAllowed { t.Fatalf("expected %v, got %v", tt.expectAllowed, allowed) } @@ -611,7 +611,7 @@ func TestCompileAuthorizationInvalidExpression(t *testing.T) { Target: &filterapi.MCPAuthorizationTarget{ Tools: []filterapi.ToolCall{{ Backend: "backend1", - ToolName: "tool1", + Tool: "tool1", Condition: strPtr("args."), }}, }, 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 16613d61a..2bd438660 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 @@ -662,12 +662,12 @@ spec: Guard against missing fields with null checks (e.g., args["foo"] != null && args["foo"]["bar"] == "val"). maxLength: 4096 type: string - toolName: - description: ToolName is the name of the tool. + tool: + description: Tool is the name of the tool. type: string required: - backend - - toolName + - tool type: object maxItems: 16 minItems: 1 diff --git a/site/docs/api/api.mdx b/site/docs/api/api.mdx index 99b080d32..66b5635bf 100644 --- a/site/docs/api/api.mdx +++ b/site/docs/api/api.mdx @@ -1983,10 +1983,10 @@ ToolCall represents a tool call in the MCP authorization target. required="true" description="Backend is the name of the backend this tool belongs to." />