Skip to content

Commit af744f6

Browse files
committed
Apply stateless mode to all transports
Move stateless configuration outside the remote-URL guard so --stateless works for both remote URLs and local container workloads. Add Allow header to 405 responses per RFC 9110. Update comments and flag text to remove remote-only language. Signed-off-by: Greg Katz <gkatz@indeed.com>
1 parent 8c16dd1 commit af744f6

11 files changed

Lines changed: 36 additions & 33 deletions

File tree

cmd/thv/app/run_flags.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ type RunFlags struct {
5858
// Remote MCP server support
5959
RemoteURL string
6060

61-
// Stateless indicates the remote server is stateless (POST-only, no SSE)
61+
// Stateless indicates the server is stateless (POST-only, no SSE)
6262
Stateless bool
6363

6464
// Security and audit
@@ -257,7 +257,7 @@ func AddRunFlags(cmd *cobra.Command, config *RunFlags) {
257257
"Trust X-Forwarded-* headers from reverse proxies (X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port, X-Forwarded-Prefix) "+
258258
"(default false)")
259259
cmd.Flags().BoolVar(&config.Stateless, "stateless", false,
260-
"Declare the remote server as stateless (POST-only, no SSE). "+
260+
"Declare the server as stateless (POST-only, no SSE). "+
261261
"Use for MCP servers implementing streamable-HTTP stateless mode.")
262262
cmd.Flags().StringVar(&config.EndpointPrefix, "endpoint-prefix", "",
263263
"Path prefix to prepend to SSE endpoint URLs (e.g., /playwright)")

docs/cli/thv_run.md

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/docs.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/swagger.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/swagger.yaml

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

pkg/runner/config.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,10 +191,10 @@ type RunConfig struct {
191191
// TrustProxyHeaders indicates whether to trust X-Forwarded-* headers from reverse proxies
192192
TrustProxyHeaders bool `json:"trust_proxy_headers,omitempty" yaml:"trust_proxy_headers,omitempty"`
193193

194-
// Stateless indicates the remote server only supports POST (no SSE/GET).
194+
// Stateless indicates the server only supports POST (no SSE/GET).
195195
// When true, the proxy returns 405 for incoming GET requests and uses a
196196
// POST-based health check instead of the default GET probe.
197-
// Only meaningful when RemoteURL is set.
197+
// Applies to both remote URLs and local container workloads.
198198
Stateless bool `json:"stateless,omitempty" yaml:"stateless,omitempty"`
199199

200200
// ProxyMode is the effective HTTP protocol the proxy uses.

pkg/runner/config_builder.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ func WithTrustProxyHeaders(trust bool) RunConfigBuilderOption {
319319
}
320320
}
321321

322-
// WithStateless declares the remote server is stateless (POST-only, no SSE).
322+
// WithStateless declares the server is stateless (POST-only, no SSE).
323323
func WithStateless(stateless bool) RunConfigBuilderOption {
324324
return func(b *runConfigBuilder) error {
325325
b.config.Stateless = stateless

pkg/runner/runner.go

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -305,12 +305,6 @@ func (r *Runner) Run(ctx context.Context) error {
305305
// Set up the transport
306306
slog.Debug("setting up transport", "transport", r.Config.Transport)
307307

308-
// Warn if stateless mode is set for a non-remote workload — it has no effect.
309-
if r.Config.Stateless && r.Config.RemoteURL == "" {
310-
slog.Warn("--stateless has no effect for local container workloads; use it only with remote URLs",
311-
"server", r.Config.BaseName)
312-
}
313-
314308
// Prepare transport options based on workload type
315309
var transportOpts []transport.Option
316310
var setupResult *runtime.SetupResult
@@ -431,16 +425,6 @@ func (r *Runner) Run(ctx context.Context) error {
431425
}
432426
})
433427

434-
// Configure stateless mode if requested. Remote workloads always use
435-
// HTTPTransport, so a failed cast here is a programming error.
436-
if r.Config.Stateless {
437-
httpT, ok := transportHandler.(*transport.HTTPTransport)
438-
if !ok {
439-
return fmt.Errorf("internal error: remote transport is not an HTTPTransport")
440-
}
441-
httpT.SetStateless(true)
442-
}
443-
444428
// Set the unauthorized response callback for bearer token authentication
445429
errorMsg := "Bearer token authentication failed. Please restart the server with a new token"
446430
transportHandler.SetOnUnauthorizedResponse(func() {
@@ -459,6 +443,17 @@ func (r *Runner) Run(ctx context.Context) error {
459443
})
460444
}
461445

446+
// Configure stateless mode if requested. Stateless mode applies to any
447+
// streamable-HTTP server (remote or local container) where the upstream
448+
// only accepts POST and does not support SSE-based sessions.
449+
if r.Config.Stateless {
450+
httpT, ok := transportHandler.(*transport.HTTPTransport)
451+
if !ok {
452+
return fmt.Errorf("--stateless requires streamable-HTTP or SSE transport, got %T", transportHandler)
453+
}
454+
httpT.SetStateless(true)
455+
}
456+
462457
// Start the transport (which also starts the container and monitoring)
463458
slog.Debug("starting transport", "transport", r.Config.Transport, "container", r.Config.ContainerName)
464459
if err := transportHandler.Start(ctx); err != nil {

pkg/transport/http.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ type HTTPTransport struct {
5858
// Remote MCP server support
5959
remoteURL string
6060

61-
// stateless indicates the remote server is POST-only (no SSE/GET support)
61+
// stateless indicates the server is POST-only (no SSE/GET support)
6262
stateless bool
6363

6464
// tokenSource is the OAuth token source for remote authentication
@@ -155,7 +155,7 @@ func (t *HTTPTransport) SetOnHealthCheckFailed(callback types.HealthCheckFailedC
155155
t.onHealthCheckFailed = callback
156156
}
157157

158-
// SetStateless configures the transport for a stateless remote server.
158+
// SetStateless configures the transport for a stateless server.
159159
func (t *HTTPTransport) SetStateless(stateless bool) {
160160
t.stateless = stateless
161161
}

pkg/transport/proxy/transparent/method_gate_test.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,25 @@ func TestStatelessMethodGate(t *testing.T) {
2222
name string
2323
method string
2424
expectedStatus int
25+
expectAllow bool
2526
}{
2627
{
27-
name: "GET returns 405",
28+
name: "GET returns 405 with Allow header",
2829
method: http.MethodGet,
2930
expectedStatus: http.StatusMethodNotAllowed,
31+
expectAllow: true,
3032
},
3133
{
32-
name: "HEAD returns 405",
34+
name: "HEAD returns 405 with Allow header",
3335
method: http.MethodHead,
3436
expectedStatus: http.StatusMethodNotAllowed,
37+
expectAllow: true,
3538
},
3639
{
37-
name: "DELETE returns 405",
40+
name: "DELETE returns 405 with Allow header",
3841
method: http.MethodDelete,
3942
expectedStatus: http.StatusMethodNotAllowed,
43+
expectAllow: true,
4044
},
4145
{
4246
name: "POST is forwarded",
@@ -61,6 +65,9 @@ func TestStatelessMethodGate(t *testing.T) {
6165
handler.ServeHTTP(rec, req)
6266

6367
assert.Equal(t, tc.expectedStatus, rec.Code)
68+
if tc.expectAllow {
69+
assert.Equal(t, "POST, OPTIONS", rec.Header().Get("Allow"))
70+
}
6471
})
6572
}
6673
}

0 commit comments

Comments
 (0)