Skip to content

Commit 30ae678

Browse files
committed
feat make proxy request-body limit configurable
Signed-off-by: Sanskarzz <sanskar.gur@gmail.com>
1 parent e25de16 commit 30ae678

27 files changed

Lines changed: 406 additions & 68 deletions

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,12 @@ type MCPServerSpec struct {
375375
// +optional
376376
EndpointPrefix string `json:"endpointPrefix,omitempty"`
377377

378+
// MaxRequestBodySize is the maximum inbound MCP proxy request body size in bytes.
379+
// Zero uses the default limit of 8 MiB.
380+
// +kubebuilder:validation:Minimum=0
381+
// +optional
382+
MaxRequestBodySize int64 `json:"maxRequestBodySize,omitempty"`
383+
378384
// GroupRef references the MCPGroup this server belongs to.
379385
// The referenced MCPGroup must be in the same namespace.
380386
// +optional

cmd/thv-operator/controllers/mcpserver_runconfig.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ func (r *MCPServerReconciler) createRunConfigFromMCPServer(m *mcpv1beta1.MCPServ
151151
runner.WithHost(proxyHost),
152152
runner.WithTrustProxyHeaders(m.Spec.TrustProxyHeaders),
153153
runner.WithEndpointPrefix(m.Spec.EndpointPrefix),
154+
runner.WithMaxRequestBodySize(m.Spec.MaxRequestBodySize),
154155
runner.WithToolsFilter(toolsFilter),
155156
runner.WithEnvVars(envVars),
156157
runner.WithVolumes(volumes),

cmd/thv-operator/controllers/mcpserver_runconfig_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,25 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) {
348348
assert.Equal(t, "[]", cedarCfg.Options.EntitiesJSON)
349349
},
350350
},
351+
{
352+
name: "max request body size omitted",
353+
mcpServer: v1beta1test.NewMCPServer("body-limit-omitted", "test-ns"),
354+
//nolint:thelper // We want to see the error at the specific line
355+
expected: func(t *testing.T, config *runner.RunConfig) {
356+
assert.Zero(t, config.MaxRequestBodySize)
357+
},
358+
},
359+
{
360+
name: "with max request body size",
361+
mcpServer: v1beta1test.NewMCPServer("body-limit-configured", "test-ns",
362+
v1beta1test.Mutate(func(m *mcpv1beta1.MCPServer) {
363+
m.Spec.MaxRequestBodySize = 16 << 20
364+
})),
365+
//nolint:thelper // We want to see the error at the specific line
366+
expected: func(t *testing.T, config *runner.RunConfig) {
367+
assert.Equal(t, int64(16<<20), config.MaxRequestBodySize)
368+
},
369+
},
351370
}
352371

353372
for _, tt := range tests {
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package controllers
5+
6+
import (
7+
. "github.com/onsi/ginkgo/v2"
8+
. "github.com/onsi/gomega"
9+
apierrors "k8s.io/apimachinery/pkg/api/errors"
10+
)
11+
12+
var _ = Describe("Schema validation for MCPServer request body limits",
13+
Label("k8s", "validation"), func() {
14+
It("accepts an omitted value", func() {
15+
server := newMinimalMCPServer("mcp-body-limit-omitted", nil)
16+
Expect(k8sClient.Create(ctx, server)).To(Succeed())
17+
})
18+
19+
It("accepts a positive value", func() {
20+
server := newMinimalMCPServer("mcp-body-limit-positive", nil)
21+
server.Spec.MaxRequestBodySize = 16 << 20
22+
Expect(k8sClient.Create(ctx, server)).To(Succeed())
23+
})
24+
25+
It("rejects a negative value", func() {
26+
server := newMinimalMCPServer("mcp-body-limit-negative", nil)
27+
server.Spec.MaxRequestBodySize = -1
28+
29+
err := k8sClient.Create(ctx, server)
30+
Expect(apierrors.IsInvalid(err)).To(BeTrue())
31+
Expect(err.Error()).To(ContainSubstring("spec.maxRequestBodySize"))
32+
})
33+
})

cmd/thv/app/proxy.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,14 +108,18 @@ Dynamic client registration (automatic OAuth client setup):
108108
thv proxy my-server --target-uri https://protected-api.com \
109109
--remote-auth --remote-auth-issuer https://auth.example.com`,
110110
Args: cobra.ExactArgs(1),
111+
PreRunE: func(_ *cobra.Command, _ []string) error {
112+
return validateProxyMaxRequestBodySize(proxyMaxRequestBodySize)
113+
},
111114
RunE: proxyCmdFunc,
112115
}
113116

114117
var (
115-
proxyHost string
116-
proxyPort int
117-
proxyTargetURI string
118-
proxyAllowedOrigins []string
118+
proxyHost string
119+
proxyPort int
120+
proxyTargetURI string
121+
proxyAllowedOrigins []string
122+
proxyMaxRequestBodySize int64
119123

120124
resourceURL string // Explicit resource URL for OAuth discovery endpoint (RFC 9728)
121125

@@ -140,6 +144,8 @@ func init() {
140144
"Exact-match allowlist for the HTTP Origin header (repeatable). Recommended when binding publicly; "+
141145
"loopback binds derive a default allowlist automatically, non-loopback binds log a warning when "+
142146
"no value is supplied. Example: https://my-mcp.example.com")
147+
proxyCmd.Flags().Int64Var(&proxyMaxRequestBodySize, "max-request-body-size", 0,
148+
"Maximum inbound request body size in bytes; zero uses the default (8 MiB)")
143149
proxyCmd.Flags().StringVar(
144150
&proxyTargetURI,
145151
"target-uri",
@@ -238,7 +244,7 @@ func proxyCmdFunc(cmd *cobra.Command, args []string) error {
238244
// runner's addBodyLimitMiddleware. See pkg/bodylimit.
239245
middlewares = append(middlewares, types.NamedMiddleware{
240246
Name: bodylimit.MiddlewareType,
241-
Function: bodylimit.Middleware(bodylimit.DefaultMaxRequestBodySize),
247+
Function: bodylimit.Middleware(proxyMaxRequestBodySize),
242248
})
243249

244250
// Origin-header validation (DNS-rebinding protection per MCP 2025-11-25
@@ -323,6 +329,13 @@ func proxyCmdFunc(cmd *cobra.Command, args []string) error {
323329
return proxy.Stop(shutdownCtx)
324330
}
325331

332+
func validateProxyMaxRequestBodySize(maxBytes int64) error {
333+
if maxBytes < 0 {
334+
return fmt.Errorf("max-request-body-size must be non-negative, got %d", maxBytes)
335+
}
336+
return nil
337+
}
338+
326339
// getProxyOIDCConfig returns the OIDC token validator config from CLI flags, or nil if OIDC is not enabled.
327340
func getProxyOIDCConfig(cmd *cobra.Command) *auth.TokenValidatorConfig {
328341
if !IsOIDCEnabled(cmd) {

cmd/thv/app/proxy_test.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc.
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

44
package app
@@ -8,8 +8,37 @@ import (
88
"testing"
99

1010
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
1112
)
1213

14+
func TestValidateProxyMaxRequestBodySize(t *testing.T) {
15+
t.Parallel()
16+
17+
tests := []struct {
18+
name string
19+
maxBytes int64
20+
wantError bool
21+
}{
22+
{name: "zero uses default", maxBytes: 0},
23+
{name: "positive value", maxBytes: 16 << 20},
24+
{name: "negative value", maxBytes: -1, wantError: true},
25+
}
26+
27+
for _, tt := range tests {
28+
t.Run(tt.name, func(t *testing.T) {
29+
t.Parallel()
30+
31+
err := validateProxyMaxRequestBodySize(tt.maxBytes)
32+
if tt.wantError {
33+
require.Error(t, err)
34+
assert.Contains(t, err.Error(), "max-request-body-size must be non-negative")
35+
return
36+
}
37+
require.NoError(t, err)
38+
})
39+
}
40+
}
41+
1342
// TestBuildRemoteAuthFlowConfig_Trust covers the trust flags this function
1443
// derives. The target URIs are IP literals so no DNS lookup is involved.
1544
func TestBuildRemoteAuthFlowConfig_Trust(t *testing.T) {

cmd/thv/app/run_flags.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@ type RunFlags struct {
112112
// SessionTTL is the session inactivity timeout. Zero uses the transport default.
113113
SessionTTL time.Duration
114114

115+
// MaxRequestBodySize is the maximum inbound request body size in bytes. Zero uses the default.
116+
MaxRequestBodySize int64
117+
115118
// Network mode
116119
Network string
117120

@@ -295,6 +298,8 @@ func AddRunFlags(cmd *cobra.Command, config *RunFlags) {
295298
"Use for MCP servers implementing streamable-HTTP stateless mode.")
296299
cmd.Flags().DurationVar(&config.SessionTTL, "session-ttl", 0,
297300
"Session inactivity timeout (e.g., 30m, 2h); zero uses the default (2h)")
301+
cmd.Flags().Int64Var(&config.MaxRequestBodySize, "max-request-body-size", 0,
302+
"Maximum inbound request body size in bytes; zero uses the default (8 MiB)")
298303
cmd.Flags().StringVar(&config.EndpointPrefix, "endpoint-prefix", "",
299304
"Path prefix to prepend to SSE endpoint URLs (e.g., /playwright)")
300305
cmd.Flags().StringVar(&config.Network, "network", "",
@@ -707,6 +712,7 @@ func buildRunnerConfig(
707712
runner.WithStrictProtocolValidation(runFlags.StrictProtocolValidation),
708713
runner.WithStateless(runFlags.Stateless),
709714
runner.WithSessionTTL(runFlags.SessionTTL),
715+
runner.WithMaxRequestBodySize(runFlags.MaxRequestBodySize),
710716
runner.WithEndpointPrefix(runFlags.EndpointPrefix),
711717
runner.WithNetworkMode(runFlags.Network),
712718
runner.WithK8sPodPatch(runFlags.K8sPodPatch),

cmd/thv/app/run_flags_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -870,3 +870,49 @@ func TestBuildRunnerConfig_NetworkIsolationExplicitWiring(t *testing.T) {
870870
})
871871
}
872872
}
873+
874+
// TestBuildRunnerConfig_MaxRequestBodySizeWiring guards the CLI-to-RunConfig
875+
// handoff for --max-request-body-size. The builder tests cover validation in
876+
// isolation; this test ensures the command layer does not drop the flag value.
877+
func TestBuildRunnerConfig_MaxRequestBodySizeWiring(t *testing.T) {
878+
t.Parallel()
879+
880+
tests := []struct {
881+
name string
882+
flagValue string
883+
want int64
884+
wantErr bool
885+
}{
886+
{name: "zero preserves default semantics", flagValue: "0", want: 0},
887+
{name: "positive value is wired", flagValue: "16777216", want: 16 << 20},
888+
{name: "negative value is rejected", flagValue: "-1", wantErr: true},
889+
}
890+
891+
for _, tt := range tests {
892+
t.Run(tt.name, func(t *testing.T) {
893+
t.Parallel()
894+
895+
runFlags := &RunFlags{}
896+
cmd := &cobra.Command{}
897+
AddRunFlags(cmd, runFlags)
898+
899+
require.NoError(t, cmd.Flags().Set("permission-profile", "none"))
900+
require.NoError(t, cmd.Flags().Set("transport", "stdio"))
901+
require.NoError(t, cmd.Flags().Set("max-request-body-size", tt.flagValue))
902+
903+
cfg, err := buildRunnerConfig(
904+
t.Context(), runFlags, nil, false, "127.0.0.1", nil, "test:latest", nil,
905+
map[string]string{}, &runner.DetachedEnvVarValidator{}, nil, nil, &config.Config{},
906+
)
907+
908+
if tt.wantErr {
909+
require.Error(t, err)
910+
assert.Contains(t, err.Error(), "max-request-body-size must be non-negative")
911+
return
912+
}
913+
require.NoError(t, err)
914+
require.NotNil(t, cfg)
915+
assert.Equal(t, tt.want, cfg.MaxRequestBodySize)
916+
})
917+
}
918+
}

deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,13 @@ spec:
291291
image:
292292
description: Image is the container image for the MCP server
293293
type: string
294+
maxRequestBodySize:
295+
description: |-
296+
MaxRequestBodySize is the maximum inbound MCP proxy request body size in bytes.
297+
Zero uses the default limit of 8 MiB.
298+
format: int64
299+
minimum: 0
300+
type: integer
294301
mcpPort:
295302
description: MCPPort is the port that MCP server listens to
296303
format: int32
@@ -1235,6 +1242,13 @@ spec:
12351242
image:
12361243
description: Image is the container image for the MCP server
12371244
type: string
1245+
maxRequestBodySize:
1246+
description: |-
1247+
MaxRequestBodySize is the maximum inbound MCP proxy request body size in bytes.
1248+
Zero uses the default limit of 8 MiB.
1249+
format: int64
1250+
minimum: 0
1251+
type: integer
12381252
mcpPort:
12391253
description: MCPPort is the port that MCP server listens to
12401254
format: int32

deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,13 @@ spec:
294294
image:
295295
description: Image is the container image for the MCP server
296296
type: string
297+
maxRequestBodySize:
298+
description: |-
299+
MaxRequestBodySize is the maximum inbound MCP proxy request body size in bytes.
300+
Zero uses the default limit of 8 MiB.
301+
format: int64
302+
minimum: 0
303+
type: integer
297304
mcpPort:
298305
description: MCPPort is the port that MCP server listens to
299306
format: int32
@@ -1238,6 +1245,13 @@ spec:
12381245
image:
12391246
description: Image is the container image for the MCP server
12401247
type: string
1248+
maxRequestBodySize:
1249+
description: |-
1250+
MaxRequestBodySize is the maximum inbound MCP proxy request body size in bytes.
1251+
Zero uses the default limit of 8 MiB.
1252+
format: int64
1253+
minimum: 0
1254+
type: integer
12411255
mcpPort:
12421256
description: MCPPort is the port that MCP server listens to
12431257
format: int32

0 commit comments

Comments
 (0)