Skip to content

Commit dfe941c

Browse files
committed
Configure rate limits on VirtualMCPServer
Signed-off-by: Sanskarzz <sanskar.gur@gmail.com>
1 parent 624c6d0 commit dfe941c

30 files changed

Lines changed: 2184 additions & 159 deletions

cmd/thv-operator/api/v1beta1/mcpserver_types.go

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
corev1 "k8s.io/api/core/v1"
88
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
99
"k8s.io/apimachinery/pkg/runtime"
10+
11+
rlconfig "github.com/stacklok/toolhive/pkg/ratelimit/config"
1012
)
1113

1214
// Condition types for MCPServer
@@ -505,11 +507,15 @@ type SessionStorageConfig struct {
505507
// RateLimitConfig defines rate limiting configuration for an MCP server.
506508
// At least one of shared, perUser, or tools must be configured.
507509
//
508-
// +kubebuilder:validation:XValidation:rule="has(self.shared) || has(self.perUser) || (has(self.tools) && size(self.tools) > 0)",message="at least one of shared, perUser, or tools must be configured"
510+
// +kubebuilder:validation:XValidation:rule="has(self.global) || has(self.shared) || has(self.perUser) || (has(self.tools) && size(self.tools) > 0)",message="at least one of global, shared, perUser, or tools must be configured"
509511
//
510512
//nolint:lll // CEL validation rules exceed line length limit
511513
type RateLimitConfig struct {
512-
// Shared is a token bucket shared across all users for the entire server.
514+
// Global is a token bucket shared across all users for the entire server.
515+
// +optional
516+
Global *RateLimitBucket `json:"global,omitempty"`
517+
518+
// Shared is a deprecated alias for Global. Use global instead.
513519
// +optional
514520
Shared *RateLimitBucket `json:"shared,omitempty"`
515521

@@ -547,9 +553,9 @@ type RateLimitBucket struct {
547553
}
548554

549555
// ToolRateLimitConfig defines rate limits for a specific tool.
550-
// At least one of shared or perUser must be configured.
556+
// At least one of global, shared, or perUser must be configured.
551557
//
552-
// +kubebuilder:validation:XValidation:rule="has(self.shared) || has(self.perUser)",message="at least one of shared or perUser must be configured"
558+
// +kubebuilder:validation:XValidation:rule="has(self.global) || has(self.shared) || has(self.perUser)",message="at least one of global, shared, or perUser must be configured"
553559
//
554560
//nolint:lll // kubebuilder marker exceeds line length
555561
type ToolRateLimitConfig struct {
@@ -558,7 +564,11 @@ type ToolRateLimitConfig struct {
558564
// +kubebuilder:validation:MinLength=1
559565
Name string `json:"name"`
560566

561-
// Shared token bucket for this specific tool.
567+
// Global token bucket for this specific tool.
568+
// +optional
569+
Global *RateLimitBucket `json:"global,omitempty"`
570+
571+
// Shared is a deprecated alias for Global. Use global instead.
562572
// +optional
563573
Shared *RateLimitBucket `json:"shared,omitempty"`
564574

@@ -567,6 +577,40 @@ type ToolRateLimitConfig struct {
567577
PerUser *RateLimitBucket `json:"perUser,omitempty"`
568578
}
569579

580+
// ToInternal converts the Kubernetes API rate limit config to the runtime-neutral representation.
581+
func (in *RateLimitConfig) ToInternal() *rlconfig.Config {
582+
if in == nil {
583+
return nil
584+
}
585+
out := &rlconfig.Config{
586+
Global: rateLimitBucketToInternal(in.Global),
587+
Shared: rateLimitBucketToInternal(in.Shared),
588+
PerUser: rateLimitBucketToInternal(in.PerUser),
589+
}
590+
if len(in.Tools) > 0 {
591+
out.Tools = make([]rlconfig.ToolConfig, 0, len(in.Tools))
592+
for _, tool := range in.Tools {
593+
out.Tools = append(out.Tools, rlconfig.ToolConfig{
594+
Name: tool.Name,
595+
Global: rateLimitBucketToInternal(tool.Global),
596+
Shared: rateLimitBucketToInternal(tool.Shared),
597+
PerUser: rateLimitBucketToInternal(tool.PerUser),
598+
})
599+
}
600+
}
601+
return out
602+
}
603+
604+
func rateLimitBucketToInternal(in *RateLimitBucket) *rlconfig.Bucket {
605+
if in == nil {
606+
return nil
607+
}
608+
return &rlconfig.Bucket{
609+
MaxTokens: in.MaxTokens,
610+
RefillPeriod: in.RefillPeriod,
611+
}
612+
}
613+
570614
// Permission profile types
571615
const (
572616
// PermissionProfileTypeBuiltin is the type for built-in permission profiles

cmd/thv-operator/api/v1beta1/mcpserver_types_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,13 @@ func TestRateLimitConfigJSONRoundtrip(t *testing.T) {
7575
input RateLimitConfig
7676
wantJSON string
7777
}{
78+
{
79+
name: "global only",
80+
input: RateLimitConfig{
81+
Global: &RateLimitBucket{MaxTokens: 100, RefillPeriod: metav1.Duration{Duration: time.Minute}},
82+
},
83+
wantJSON: `{"global":{"maxTokens":100,"refillPeriod":"1m0s"}}`,
84+
},
7885
{
7986
name: "shared only",
8087
input: RateLimitConfig{
@@ -116,6 +123,44 @@ func TestRateLimitConfigJSONRoundtrip(t *testing.T) {
116123
}
117124
}
118125

126+
func TestVirtualMCPServerSpecRateLimitingJSONRoundtrip(t *testing.T) {
127+
t.Parallel()
128+
129+
spec := VirtualMCPServerSpec{
130+
IncomingAuth: &IncomingAuthConfig{Type: "oidc"},
131+
GroupRef: &MCPGroupRef{Name: "group-a"},
132+
SessionStorage: &SessionStorageConfig{
133+
Provider: "redis",
134+
Address: "redis.default.svc.cluster.local:6379",
135+
},
136+
RateLimiting: &RateLimitConfig{
137+
Global: &RateLimitBucket{MaxTokens: 10, RefillPeriod: metav1.Duration{Duration: time.Minute}},
138+
PerUser: &RateLimitBucket{
139+
MaxTokens: 2,
140+
RefillPeriod: metav1.Duration{Duration: time.Minute},
141+
},
142+
Tools: []ToolRateLimitConfig{
143+
{
144+
Name: "backend_a_echo",
145+
Global: &RateLimitBucket{
146+
MaxTokens: 5,
147+
RefillPeriod: metav1.Duration{Duration: 30 * time.Second},
148+
},
149+
},
150+
},
151+
},
152+
}
153+
154+
b, err := json.Marshal(spec)
155+
require.NoError(t, err)
156+
out := string(b)
157+
assert.Contains(t, out, `"rateLimiting"`)
158+
assert.Contains(t, out, `"global"`)
159+
assert.Contains(t, out, `"perUser"`)
160+
assert.Contains(t, out, `"backend_a_echo"`)
161+
assert.NotContains(t, out, `"config":{"rateLimiting"`)
162+
}
163+
119164
func TestMCPServerSpecScalingFieldsJSONRoundtrip(t *testing.T) {
120165
t.Parallel()
121166

cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ import (
1515

1616
// VirtualMCPServerSpec defines the desired state of VirtualMCPServer
1717
//
18+
// +kubebuilder:validation:XValidation:rule="!has(self.rateLimiting) || (has(self.sessionStorage) && self.sessionStorage.provider == 'redis')",message="rateLimiting requires sessionStorage with provider 'redis'"
19+
// +kubebuilder:validation:XValidation:rule="!(has(self.rateLimiting) && has(self.rateLimiting.perUser)) || (has(self.incomingAuth) && self.incomingAuth.type == 'oidc')",message="rateLimiting.perUser requires incomingAuth.type oidc"
20+
// +kubebuilder:validation:XValidation:rule="!has(self.rateLimiting) || !has(self.rateLimiting.tools) || self.rateLimiting.tools.all(t, !has(t.perUser)) || (has(self.incomingAuth) && self.incomingAuth.type == 'oidc')",message="per-tool perUser rate limiting requires incomingAuth.type oidc"
21+
//
1822
//nolint:lll // CEL validation rules exceed line length limit
1923
type VirtualMCPServerSpec struct {
2024
// IncomingAuth configures authentication for clients connecting to the Virtual MCP server.
@@ -107,6 +111,11 @@ type VirtualMCPServerSpec struct {
107111
// When nil, no session storage is configured.
108112
// +optional
109113
SessionStorage *SessionStorageConfig `json:"sessionStorage,omitempty"`
114+
115+
// RateLimiting defines rate limiting configuration for the Virtual MCP server.
116+
// Requires Redis session storage to be configured for distributed rate limiting.
117+
// +optional
118+
RateLimiting *RateLimitConfig `json:"rateLimiting,omitempty"`
110119
}
111120

112121
// EmbeddingServerRef references an existing EmbeddingServer resource by name.

cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go

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

cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,25 @@ func TestEnsureVmcpConfigConfigMap(t *testing.T) {
459459
},
460460
Spec: mcpv1beta1.VirtualMCPServerSpec{
461461
GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"},
462+
SessionStorage: &mcpv1beta1.SessionStorageConfig{
463+
Provider: mcpv1beta1.SessionStorageProviderRedis,
464+
Address: "redis.default.svc.cluster.local:6379",
465+
},
466+
RateLimiting: &mcpv1beta1.RateLimitConfig{
467+
PerUser: &mcpv1beta1.RateLimitBucket{
468+
MaxTokens: 2,
469+
RefillPeriod: metav1.Duration{Duration: time.Minute},
470+
},
471+
Tools: []mcpv1beta1.ToolRateLimitConfig{
472+
{
473+
Name: "backend_a_echo",
474+
Global: &mcpv1beta1.RateLimitBucket{
475+
MaxTokens: 5,
476+
RefillPeriod: metav1.Duration{Duration: time.Minute},
477+
},
478+
},
479+
},
480+
},
462481
},
463482
}
464483

@@ -507,6 +526,16 @@ func TestEnsureVmcpConfigConfigMap(t *testing.T) {
507526
assert.Equal(t, "test-vmcp-vmcp-config", cm.Name)
508527
assert.Contains(t, cm.Data, "config.yaml")
509528
assert.NotEmpty(t, cm.Annotations["toolhive.stacklok.dev/content-checksum"])
529+
530+
var cfg vmcpconfig.Config
531+
require.NoError(t, yaml.Unmarshal([]byte(cm.Data["config.yaml"]), &cfg))
532+
require.NotNil(t, cfg.RateLimiting, "runtime config must include spec.rateLimiting")
533+
require.NotNil(t, cfg.RateLimiting.PerUser)
534+
assert.Equal(t, int32(2), cfg.RateLimiting.PerUser.MaxTokens)
535+
require.Len(t, cfg.RateLimiting.Tools, 1)
536+
assert.Equal(t, "backend_a_echo", cfg.RateLimiting.Tools[0].Name)
537+
require.NotNil(t, cfg.RateLimiting.Tools[0].Global)
538+
assert.Equal(t, int32(5), cfg.RateLimiting.Tools[0].Global.MaxTokens)
510539
}
511540

512541
// TestSetAuthConfigConditions tests that auth config conditions reflect the current state

cmd/thv-operator/pkg/vmcpconfig/converter.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ func (c *Converter) Convert(
140140
}
141141

142142
config.SessionStorage = convertSessionStorage(vmcp)
143+
config.RateLimiting = vmcp.Spec.RateLimiting.ToInternal()
143144

144145
// Apply operational defaults (fills missing values)
145146
config.EnsureOperationalDefaults()

cmd/thv-operator/pkg/vmcpconfig/converter_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,42 @@ func newTestConverterWithObjects(t *testing.T, resolver oidc.Resolver, objects .
9797
return converter
9898
}
9999

100+
func TestConverter_RateLimitingFromTopLevelSpec(t *testing.T) {
101+
t.Parallel()
102+
103+
vmcp := &mcpv1beta1.VirtualMCPServer{
104+
ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"},
105+
Spec: mcpv1beta1.VirtualMCPServerSpec{
106+
GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"},
107+
IncomingAuth: &mcpv1beta1.IncomingAuthConfig{Type: "anonymous"},
108+
RateLimiting: &mcpv1beta1.RateLimitConfig{
109+
Global: &mcpv1beta1.RateLimitBucket{
110+
MaxTokens: 10,
111+
RefillPeriod: metav1.Duration{Duration: time.Minute},
112+
},
113+
Tools: []mcpv1beta1.ToolRateLimitConfig{
114+
{
115+
Name: "backend_a_echo",
116+
Global: &mcpv1beta1.RateLimitBucket{
117+
MaxTokens: 1,
118+
RefillPeriod: metav1.Duration{Duration: time.Minute},
119+
},
120+
},
121+
},
122+
},
123+
},
124+
}
125+
126+
converter := newTestConverter(t, newNoOpMockResolver(t))
127+
cfg, _, err := converter.Convert(context.Background(), vmcp, nil)
128+
require.NoError(t, err)
129+
require.NotNil(t, cfg.RateLimiting)
130+
require.NotNil(t, cfg.RateLimiting.Global)
131+
assert.Equal(t, int32(10), cfg.RateLimiting.Global.MaxTokens)
132+
require.Len(t, cfg.RateLimiting.Tools, 1)
133+
assert.Equal(t, "backend_a_echo", cfg.RateLimiting.Tools[0].Name)
134+
}
135+
100136
func TestConverter_OIDCResolution(t *testing.T) {
101137
t.Parallel()
102138

cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_sessionstorage_cel_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
package controllers
66

77
import (
8+
"time"
9+
810
. "github.com/onsi/ginkgo/v2"
911
. "github.com/onsi/gomega"
1012
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -106,4 +108,57 @@ var _ = Describe("CEL Validation for SessionStorageConfig on VirtualMCPServer",
106108
Expect(err).To(HaveOccurred())
107109
})
108110
})
111+
112+
Context("rateLimiting", func() {
113+
It("should reject rate limiting without redis session storage", func() {
114+
vmcp := newVirtualMCPServerWithSessionStorage("vmcp-rl-no-redis", nil)
115+
vmcp.Spec.RateLimiting = &mcpv1beta1.RateLimitConfig{
116+
Global: &mcpv1beta1.RateLimitBucket{
117+
MaxTokens: 1,
118+
RefillPeriod: metav1.Duration{Duration: time.Minute},
119+
},
120+
}
121+
122+
err := k8sClient.Create(ctx, vmcp)
123+
Expect(err).To(HaveOccurred())
124+
Expect(err.Error()).To(ContainSubstring("rateLimiting requires sessionStorage with provider 'redis'"))
125+
})
126+
127+
It("should reject perUser rate limiting with anonymous auth", func() {
128+
vmcp := newVirtualMCPServerWithSessionStorage("vmcp-rl-peruser-anon", &mcpv1beta1.SessionStorageConfig{
129+
Provider: "redis",
130+
Address: "redis:6379",
131+
})
132+
vmcp.Spec.RateLimiting = &mcpv1beta1.RateLimitConfig{
133+
PerUser: &mcpv1beta1.RateLimitBucket{
134+
MaxTokens: 1,
135+
RefillPeriod: metav1.Duration{Duration: time.Minute},
136+
},
137+
}
138+
139+
err := k8sClient.Create(ctx, vmcp)
140+
Expect(err).To(HaveOccurred())
141+
Expect(err.Error()).To(ContainSubstring("rateLimiting.perUser requires incomingAuth.type oidc"))
142+
})
143+
144+
It("should accept perUser rate limiting with oidc auth and redis session storage", func() {
145+
vmcp := newVirtualMCPServerWithSessionStorage("vmcp-rl-peruser-oidc", &mcpv1beta1.SessionStorageConfig{
146+
Provider: "redis",
147+
Address: "redis:6379",
148+
})
149+
vmcp.Spec.IncomingAuth = &mcpv1beta1.IncomingAuthConfig{
150+
Type: "oidc",
151+
OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{Name: "oidc"},
152+
}
153+
vmcp.Spec.RateLimiting = &mcpv1beta1.RateLimitConfig{
154+
PerUser: &mcpv1beta1.RateLimitBucket{
155+
MaxTokens: 1,
156+
RefillPeriod: metav1.Duration{Duration: time.Minute},
157+
},
158+
}
159+
160+
err := k8sClient.Create(ctx, vmcp)
161+
Expect(err).NotTo(HaveOccurred())
162+
})
163+
})
109164
})

0 commit comments

Comments
 (0)