Skip to content

Commit f8a07cc

Browse files
committed
use CEL for arguments matching
Signed-off-by: Huabing Zhao <[email protected]>
1 parent 6a6a8a1 commit f8a07cc

File tree

9 files changed

+215
-98
lines changed

9 files changed

+215
-98
lines changed

api/v1alpha1/mcp_route.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -305,12 +305,14 @@ type ToolCall struct {
305305
// +kubebuilder:validation:Required
306306
ToolName string `json:"toolName"`
307307

308-
// Arguments defines the arguments that must be present in the tool call for this rule to match.
309-
// Keys must exist and their values must match the provided RE2-compatible regular expressions.
310-
// If the argument is a non-string type, it will be matched against its JSON representation.
308+
// Arguments is a CEL expression that must evaluate to true for the rule to match.
309+
// The expression is evaluated with a single variable "args" bound to the tool call arguments as a dynamic object.
310+
// Guard against missing fields with null checks (e.g., args["foo"] != null && args["foo"]["bar"] == "val").
311311
//
312+
// +kubebuilder:validation:Optional
313+
// +kubebuilder:validation:MaxLength=4096
312314
// +optional
313-
Arguments map[string]string `json:"arguments,omitempty"`
315+
Arguments *string `json:"arguments,omitempty"`
314316
}
315317

316318
// JWKS defines how to obtain JSON Web Key Sets (JWKS) either from a remote HTTP/HTTPS endpoint or from a local source.

api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 2 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/filterapi/mcpconfig.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ type ToolCall struct {
108108
// ToolName is the name of the tool.
109109
ToolName string `json:"toolName"`
110110

111-
// Arguments defines required arguments (exact key names with regex patterns for values).
112-
// All patterns must match for the rule to apply.
113-
Arguments map[string]string `json:"arguments,omitempty"`
111+
// Arguments is a CEL expression evaluated against the tool call arguments map.
112+
// The expression must evaluate to true for the rule to apply.
113+
Arguments *string `json:"arguments,omitempty"`
114114
}

internal/mcpproxy/authorization.go

Lines changed: 88 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,88 @@
66
package mcpproxy
77

88
import (
9-
"encoding/json"
109
"errors"
1110
"fmt"
1211
"log/slog"
1312
"net/http"
14-
"regexp"
1513
"strings"
1614

1715
"github.com/golang-jwt/jwt/v5"
16+
"github.com/google/cel-go/cel"
17+
"github.com/google/cel-go/common/types"
1818
"k8s.io/apimachinery/pkg/util/sets"
1919

2020
"github.com/envoyproxy/ai-gateway/internal/filterapi"
2121
)
2222

23+
type compiledAuthorization struct {
24+
ResourceMetadataURL string
25+
Rules []compiledAuthorizationRule
26+
}
27+
28+
type compiledAuthorizationRule struct {
29+
Source filterapi.MCPAuthorizationSource
30+
Target []compiledToolCall
31+
}
32+
33+
type compiledToolCall struct {
34+
BackendName string
35+
ToolName string
36+
Expression string
37+
program cel.Program
38+
}
39+
40+
// compileAuthorization compiles the MCPRouteAuthorization into a compiledAuthorization for efficient CEL evaluation.
41+
func compileAuthorization(auth *filterapi.MCPRouteAuthorization) (*compiledAuthorization, error) {
42+
if auth == nil {
43+
return nil, nil
44+
}
45+
46+
env, err := cel.NewEnv(
47+
cel.Variable("args", cel.DynType),
48+
cel.OptionalTypes(),
49+
)
50+
if err != nil {
51+
return nil, fmt.Errorf("failed to create CEL environment: %w", err)
52+
}
53+
54+
compiled := &compiledAuthorization{
55+
ResourceMetadataURL: auth.ResourceMetadataURL,
56+
}
57+
58+
for _, rule := range auth.Rules {
59+
cr := compiledAuthorizationRule{
60+
Source: rule.Source,
61+
}
62+
for _, tool := range rule.Target.Tools {
63+
ct := compiledToolCall{
64+
BackendName: tool.BackendName,
65+
ToolName: tool.ToolName,
66+
}
67+
if tool.Arguments != nil && strings.TrimSpace(*tool.Arguments) != "" {
68+
expr := strings.TrimSpace(*tool.Arguments)
69+
ast, issues := env.Compile(expr)
70+
if issues != nil && issues.Err() != nil {
71+
return nil, fmt.Errorf("failed to compile arguments CEL for tool %s/%s: %w", tool.BackendName, tool.ToolName, issues.Err())
72+
}
73+
program, err := env.Program(ast, cel.CostLimit(10000))
74+
if err != nil {
75+
return nil, fmt.Errorf("failed to build arguments CEL program for tool %s/%s: %w", tool.BackendName, tool.ToolName, err)
76+
}
77+
ct.Expression = expr
78+
ct.program = program
79+
}
80+
cr.Target = append(cr.Target, ct)
81+
}
82+
compiled.Rules = append(compiled.Rules, cr)
83+
}
84+
85+
return compiled, nil
86+
}
87+
2388
// authorizeRequest authorizes the request based on the given MCPRouteAuthorization configuration.
2489

25-
func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorization, headers http.Header, backendName, toolName string, argments any) (bool, []string) {
90+
func (m *MCPProxy) authorizeRequest(authorization *compiledAuthorization, headers http.Header, backendName, toolName string, arguments any) (bool, []string) {
2691
if authorization == nil {
2792
return true, nil
2893
}
@@ -48,17 +113,11 @@ func (m *MCPProxy) authorizeRequest(authorization *filterapi.MCPRouteAuthorizati
48113
return false, nil
49114
}
50115

51-
scopeSet := sets.New[string](extractScopes(claims)...)
116+
scopeSet := sets.New(extractScopes(claims)...)
52117
var missingScopesForChallenge []string
53118

54119
for _, rule := range authorization.Rules {
55-
var args map[string]any
56-
if argments != nil {
57-
if cast, ok := argments.(map[string]any); ok {
58-
args = cast
59-
}
60-
}
61-
if !m.toolMatches(filterapi.ToolCall{BackendName: backendName, ToolName: toolName}, rule.Target.Tools, args) {
120+
if !m.toolMatches(backendName, toolName, rule.Target, arguments) {
62121
continue
63122
}
64123

@@ -117,53 +176,36 @@ func extractScopes(claims jwt.MapClaims) []string {
117176
}
118177
}
119178

120-
func (m *MCPProxy) toolMatches(target filterapi.ToolCall, tools []filterapi.ToolCall, args map[string]any) bool {
179+
func (m *MCPProxy) toolMatches(backendName, toolName string, tools []compiledToolCall, args any) bool {
121180
if len(tools) == 0 {
122181
return true
123182
}
124183

125184
for _, t := range tools {
126-
if t.BackendName != target.BackendName || t.ToolName != target.ToolName {
185+
if t.BackendName != backendName || t.ToolName != toolName {
127186
continue
128187
}
129-
if len(t.Arguments) == 0 {
188+
if t.program == nil {
130189
return true
131190
}
132-
if args == nil {
133-
return false
191+
192+
result, _, err := t.program.Eval(map[string]any{"args": args})
193+
if err != nil {
194+
m.l.Error("failed to evaluate arguments CEL", slog.String("backend", t.BackendName), slog.String("tool", t.ToolName), slog.String("error", err.Error()))
195+
continue
134196
}
135-
allMatch := true
136-
for key, pattern := range t.Arguments {
137-
rawVal, ok := args[key]
138-
if !ok {
139-
allMatch = false
140-
break
141-
}
142-
re, err := regexp.Compile(pattern)
143-
if err != nil {
144-
m.l.Error("invalid argument regex pattern", slog.String("pattern", pattern), slog.String("error", err.Error()))
145-
allMatch = false
146-
break
147-
}
148-
var data []byte
149-
if s, ok := rawVal.(string); ok {
150-
data = []byte(s)
151-
} else {
152-
jsonVal, err := json.Marshal(rawVal)
153-
if err != nil {
154-
m.l.Error("failed to marshal argument value to json", slog.String("key", key), slog.String("error", err.Error()))
155-
allMatch = false
156-
break
157-
}
158-
data = jsonVal
197+
198+
switch v := result.Value().(type) {
199+
case bool:
200+
if v {
201+
return true
159202
}
160-
if !re.Match(data) {
161-
allMatch = false
162-
break
203+
case types.Bool:
204+
if bool(v) {
205+
return true
163206
}
164-
}
165-
if allMatch {
166-
return true
207+
default:
208+
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))
167209
}
168210
}
169211
// If no matching tool entry or no arguments matched, fail.

0 commit comments

Comments
 (0)