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 @@ -420,6 +420,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 @@ -121,6 +121,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 @@ -310,6 +313,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 @@ -735,6 +740,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 @@ -2317,6 +2327,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 @@ -2320,6 +2330,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 @@ -368,6 +368,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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] Transport documentation needs timeout wording cleanup (Consensus: 7/10)

The preceding note says the transparent proxy does not impose a request timeout, which now reads as contradicting this section. Qualify it as lacking the MCP response-correlation timeout while still enforcing this HTTP read timeout; also remove the trailing whitespace on line 374 reported by git diff --check.

Raised by: general-quality

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.

5 changes: 5 additions & 0 deletions docs/server/docs.go

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

5 changes: 5 additions & 0 deletions docs/server/swagger.json

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

9 changes: 9 additions & 0 deletions docs/server/swagger.yaml

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

7 changes: 7 additions & 0 deletions pkg/runner/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,13 @@ type RunConfig struct {
// time.Duration field serializes as nanoseconds in JSON.
SessionTTL string `json:"session_ttl,omitempty" yaml:"session_ttl,omitempty" example:"2h"`

// ProxyReadTimeout bounds reading the entire request (headers + body) on the
// proxy HTTP server, expressed as a Go duration string (e.g. "30s", "1m").
// Empty uses the proxy default (30s). Negative durations and values that fail
// time.ParseDuration are rejected at runtime. Applies to all HTTP transports.
// String (not time.Duration) keeps the wire format unit-explicit.
ProxyReadTimeout string `json:"proxy_read_timeout,omitempty" yaml:"proxy_read_timeout,omitempty" example:"30s"`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Workload upgrades drop the configured proxy read timeout (Consensus: 10/10)

Adding this persisted field also requires the thv upgrade rebuild path to preserve it. preserveUserConfigFields currently copies SessionTTL at pkg/workloads/upgrade/applier.go:416 but not ProxyReadTimeout, so an upgrade silently resets any custom value to the 30s default; copy the field and extend the full-config preservation test.

Raised by: codex, general-quality, transport-architecture


// ProxyMode is the effective HTTP protocol the proxy uses.
// For stdio transports, this is the configured mode (sse or streamable-http).
// For direct transports (sse/streamable-http), this matches the transport type.
Expand Down
20 changes: 20 additions & 0 deletions pkg/runner/config_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,26 @@ func WithSessionTTL(ttl time.Duration) RunConfigBuilderOption {
}
}

// WithProxyReadTimeout sets http.Server.ReadTimeout on the proxy, bounding how
// long the server will spend reading a request (headers + body). Zero is valid
// and means "use the proxy default" (30s). Negative values return an error.
//
// The value is stored as a Go duration string on RunConfig so it survives a
// JSON/YAML round-trip; a time.Duration field would serialize as nanoseconds.
func WithProxyReadTimeout(d time.Duration) RunConfigBuilderOption {
return func(b *runConfigBuilder) error {
if d < 0 {
return fmt.Errorf("proxy-read-timeout must be non-negative, got %s", d)
}
if d == 0 {
b.config.ProxyReadTimeout = ""
return nil
}
b.config.ProxyReadTimeout = d.String()
return nil
}
}

// WithNetworkMode sets the network mode for the container.
// The network mode will be applied to the permission profile after it is loaded.
func WithNetworkMode(networkMode string) RunConfigBuilderOption {
Expand Down
43 changes: 43 additions & 0 deletions pkg/runner/config_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1542,6 +1542,49 @@ func TestWithSessionTTL(t *testing.T) {
}
}

func TestWithProxyReadTimeout(t *testing.T) {
t.Parallel()

tests := []struct {
name string
value time.Duration
expectErr bool
expectedStr string
}{
{
name: "zero is serialized as empty to use the proxy default",
value: 0,
expectedStr: "",
},
{
name: "positive duration is stored as a Go duration string",
value: 45 * time.Second,
expectedStr: "45s",
},
{
name: "negative duration returns an error",
value: -1 * time.Second,
expectErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

builder := &runConfigBuilder{config: NewRunConfig()}
err := WithProxyReadTimeout(tt.value)(builder)

if tt.expectErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expectedStr, builder.config.ProxyReadTimeout)
})
}
}

// TestWithStrictProtocolValidation verifies the builder option sets
// RunConfig.StrictProtocolValidation, mirroring WithTrustProxyHeaders's
// plumbing (see cmd/thv/app/run_flags.go's --strict-protocol-validation flag).
Expand Down
Loading
Loading