Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions cmd/thv-operator/api/v1beta1/mcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,15 @@ type MCPServerSpec struct {
// Requires Redis session storage to be configured for distributed rate limiting.
// +optional
RateLimiting *ratelimittypes.RateLimitConfig `json:"rateLimiting,omitempty"`

// ProxyReadTimeout bounds how long the proxy spends reading a full request
// (headers + body), mitigating slow-upload connection exhaustion. Applies to
// all transports. Defaults to 30s if not specified. Example: "1m".
// +kubebuilder:validation:Type=string
// +kubebuilder:validation:Format=duration
// +kubebuilder:validation:XValidation:rule="duration(self) >= duration('0s')",message="proxyReadTimeout must be non-negative"
// +optional
ProxyReadTimeout *metav1.Duration `json:"proxyReadTimeout,omitempty"`
}

// ResourceOverrides defines overrides for annotations and labels on created resources
Expand Down
5 changes: 5 additions & 0 deletions cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions cmd/thv-operator/controllers/mcpserver_runconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,11 @@ func (r *MCPServerReconciler) createRunConfigFromMCPServer(m *mcpv1beta1.MCPServ
options = append(options, runner.WithRateLimitConfig(m.Namespace, m.Spec.RateLimiting))
}

// Add proxy HTTP server read timeout if specified
if m.Spec.ProxyReadTimeout != nil {
options = append(options, runner.WithProxyReadTimeout(m.Spec.ProxyReadTimeout.Duration))
}

// Use the RunConfigBuilder for operator context with full builder pattern
runConfig, err := runner.NewOperatorRunConfigBuilder(
context.Background(),
Expand Down
31 changes: 31 additions & 0 deletions cmd/thv-operator/controllers/mcpserver_runconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fmt"
"reflect"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -61,6 +62,36 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) {
assert.Equal(t, 8080, config.Port)
},
},
{
name: "nil proxy read timeout leaves the RunConfig value empty",
mcpServer: v1beta1test.NewMCPServer("nil-timeout-server", "test-ns"),
//nolint:thelper // We want to see the error at the specific line
expected: func(t *testing.T, config *runner.RunConfig) {
assert.Empty(t, config.ProxyReadTimeout)
},
},
{
name: "zero proxy read timeout uses the proxy default",
mcpServer: v1beta1test.NewMCPServer("zero-timeout-server", "test-ns",
v1beta1test.Mutate(func(m *mcpv1beta1.MCPServer) {
m.Spec.ProxyReadTimeout = &metav1.Duration{}
})),
//nolint:thelper // We want to see the error at the specific line
expected: func(t *testing.T, config *runner.RunConfig) {
assert.Empty(t, config.ProxyReadTimeout)
},
},
{
name: "positive proxy read timeout is translated",
mcpServer: v1beta1test.NewMCPServer("positive-timeout-server", "test-ns",
v1beta1test.Mutate(func(m *mcpv1beta1.MCPServer) {
m.Spec.ProxyReadTimeout = &metav1.Duration{Duration: time.Minute}
})),
//nolint:thelper // We want to see the error at the specific line
expected: func(t *testing.T, config *runner.RunConfig) {
assert.Equal(t, "1m0s", config.ProxyReadTimeout)
},
},
{
name: "with environment variables",
mcpServer: v1beta1test.NewMCPServer("env-server", "test-ns",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
package controllers

import (
"time"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
Expand Down Expand Up @@ -117,4 +119,25 @@ var _ = Describe("CEL Validation for SessionStorageConfig on MCPServer",
Expect(err).To(HaveOccurred())
})
})

Context("proxyReadTimeout field", func() {
DescribeTable("should accept non-negative values",
func(name string, timeout *metav1.Duration) {
server := newMinimalMCPServer(name, nil)
server.Spec.ProxyReadTimeout = timeout
err := k8sClient.Create(ctx, server)
Expect(err).NotTo(HaveOccurred())
},
Entry("when omitted", "mcp-proxy-read-timeout-omitted", nil),
Entry("when zero", "mcp-proxy-read-timeout-zero", &metav1.Duration{}),
Entry("when positive", "mcp-proxy-read-timeout-positive", &metav1.Duration{Duration: 45 * time.Second}),
)

It("should reject a negative value", func() {
server := newMinimalMCPServer("mcp-proxy-read-timeout-negative", nil)
server.Spec.ProxyReadTimeout = &metav1.Duration{Duration: -time.Second}
err := k8sClient.Create(ctx, server)
Expect(err).To(MatchError(ContainSubstring("proxyReadTimeout must be non-negative")))
})
})
})
6 changes: 6 additions & 0 deletions cmd/thv/app/run_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ type RunFlags struct {
// SessionTTL is the session inactivity timeout. Zero uses the transport default.
SessionTTL time.Duration

// ProxyReadTimeout bounds reading a full request on the proxy. Zero uses the default.
ProxyReadTimeout time.Duration

// Network mode
Network string

Expand Down Expand Up @@ -295,6 +298,8 @@ func AddRunFlags(cmd *cobra.Command, config *RunFlags) {
"Use for MCP servers implementing streamable-HTTP stateless mode.")
cmd.Flags().DurationVar(&config.SessionTTL, "session-ttl", 0,
"Session inactivity timeout (e.g., 30m, 2h); zero uses the default (2h)")
cmd.Flags().DurationVar(&config.ProxyReadTimeout, "proxy-read-timeout", 0,
"Maximum time to read a full request on the proxy (e.g., 30s, 1m); zero uses the default (30s)")
cmd.Flags().StringVar(&config.EndpointPrefix, "endpoint-prefix", "",
"Path prefix to prepend to SSE endpoint URLs (e.g., /playwright)")
cmd.Flags().StringVar(&config.Network, "network", "",
Expand Down Expand Up @@ -707,6 +712,7 @@ func buildRunnerConfig(
runner.WithStrictProtocolValidation(runFlags.StrictProtocolValidation),
runner.WithStateless(runFlags.Stateless),
runner.WithSessionTTL(runFlags.SessionTTL),
runner.WithProxyReadTimeout(runFlags.ProxyReadTimeout),
runner.WithEndpointPrefix(runFlags.EndpointPrefix),
runner.WithNetworkMode(runFlags.Network),
runner.WithK8sPodPatch(runFlags.K8sPodPatch),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,16 @@ spec:
maximum: 65535
minimum: 1
type: integer
proxyReadTimeout:
description: |-
ProxyReadTimeout bounds how long the proxy spends reading a full request
(headers + body), mitigating slow-upload connection exhaustion. Applies to
all transports. Defaults to 30s if not specified. Example: "1m".
format: duration
type: string
x-kubernetes-validations:
- message: proxyReadTimeout must be non-negative
rule: duration(self) >= duration('0s')
rateLimiting:
description: |-
RateLimiting defines rate limiting configuration for the MCP server.
Expand Down Expand Up @@ -1334,6 +1344,16 @@ spec:
maximum: 65535
minimum: 1
type: integer
proxyReadTimeout:
description: |-
ProxyReadTimeout bounds how long the proxy spends reading a full request
(headers + body), mitigating slow-upload connection exhaustion. Applies to
all transports. Defaults to 30s if not specified. Example: "1m".
format: duration
type: string
x-kubernetes-validations:
- message: proxyReadTimeout must be non-negative
rule: duration(self) >= duration('0s')
rateLimiting:
description: |-
RateLimiting defines rate limiting configuration for the MCP server.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,16 @@ spec:
maximum: 65535
minimum: 1
type: integer
proxyReadTimeout:
description: |-
ProxyReadTimeout bounds how long the proxy spends reading a full request
(headers + body), mitigating slow-upload connection exhaustion. Applies to
all transports. Defaults to 30s if not specified. Example: "1m".
format: duration
type: string
x-kubernetes-validations:
- message: proxyReadTimeout must be non-negative
rule: duration(self) >= duration('0s')
rateLimiting:
description: |-
RateLimiting defines rate limiting configuration for the MCP server.
Expand Down Expand Up @@ -1337,6 +1347,16 @@ spec:
maximum: 65535
minimum: 1
type: integer
proxyReadTimeout:
description: |-
ProxyReadTimeout bounds how long the proxy spends reading a full request
(headers + body), mitigating slow-upload connection exhaustion. Applies to
all transports. Defaults to 30s if not specified. Example: "1m".
format: duration
type: string
x-kubernetes-validations:
- message: proxyReadTimeout must be non-negative
rule: duration(self) >= duration('0s')
rateLimiting:
description: |-
RateLimiting defines rate limiting configuration for the MCP server.
Expand Down
13 changes: 13 additions & 0 deletions docs/arch/03-transport-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,19 @@ thv run my-slow-server

**Note:** This timeout only affects the streamable HTTP proxy used with stdio transport. The transparent proxy used by SSE and streamable-http transports (where the container runs its own HTTP server) does not impose a request timeout.

### Proxy Request Read Timeout (All Transports)

Every proxy HTTP server limits reading a complete inbound request, including its body, to 30 seconds by default.
This prevents a slow or stalled upload from holding a connection open indefinitely.
Operators can override the limit per workload with `thv run --proxy-read-timeout` or the MCPServer `spec.proxyReadTimeout` field.
RunConfig stores the same setting as `proxy_read_timeout`, using a Go duration string such as `45s` or `2m`.

Omitting the setting or specifying zero retains the 30-second default; it never disables the timeout.
The read timeout does not limit response streaming, so long-lived SSE responses remain unaffected.

This setting is distinct from `TOOLHIVE_PROXY_REQUEST_TIMEOUT` above: the read timeout bounds the client-to-proxy HTTP upload,
while the stdio proxy request timeout bounds how long an MCP request waits for its correlated server response.

### Health Check Tuning Parameters

**Implementation**: `pkg/transport/proxy/transparent/transparent_proxy.go`
Expand Down
4 changes: 3 additions & 1 deletion docs/arch/05-runconfig-and-permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,8 @@ thv run uvx://mcp-server \
"transport": "stdio",
"host": "127.0.0.1",
"port": 8080,
"proxy_mode": "streamable-http"
"proxy_mode": "streamable-http",
"proxy_read_timeout": "45s"
}
```

Expand All @@ -146,6 +147,7 @@ thv run uvx://mcp-server \
- `target_port`: Container port (SSE/Streamable only)
- `target_host`: Container host (default: `127.0.0.1`)
- `proxy_mode`: For stdio: `sse` or `streamable-http`
- `proxy_read_timeout`: Maximum time to read a complete client request, as a Go duration string; omitted or zero uses the secure 30-second default

**Implementation**: `pkg/runner/config.go`

Expand Down
1 change: 1 addition & 0 deletions docs/cli/thv_run.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/operator/crd-api.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading