Support stateless streamable-HTTP remote servers#4515
Support stateless streamable-HTTP remote servers#4515gkatz2 wants to merge 4 commits intostacklok:mainfrom
Conversation
Remote MCP servers implementing the streamable-HTTP transport in stateless mode (POST-only, no SSE) show zero tools when proxied by ToolHive. The transparent proxy forwards all HTTP methods including GET; stateless servers don't serve SSE on GET, so clients never complete tool discovery. Add a --stateless flag to thv run that gates GET, HEAD, and DELETE requests at the proxy with 405, and switches the health check to a POST-based JSON-RPC ping appropriate for servers without an SSE endpoint. Fixes stacklok#4514 Signed-off-by: Greg Katz <gkatz@indeed.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #4515 +/- ##
==========================================
- Coverage 69.13% 68.90% -0.23%
==========================================
Files 502 505 +3
Lines 51959 52440 +481
==========================================
+ Hits 35921 36133 +212
- Misses 13254 13509 +255
- Partials 2784 2798 +14 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Regenerate CLI and Swagger docs to include the new --stateless flag added to the run command. Signed-off-by: Greg Katz <gkatz@indeed.com>
jerm-dro
left a comment
There was a problem hiding this comment.
Thanks for the PR and the well-written issue — this is a real gap for stateless streamable-HTTP servers. One blocker on the remote-only restriction, plus suggestions inline.
pkg/runner/runner.go
Outdated
| // Configure stateless mode if requested. Remote workloads always use | ||
| // HTTPTransport, so a failed cast here is a programming error. | ||
| if r.Config.Stateless { | ||
| httpT, ok := transportHandler.(*transport.HTTPTransport) | ||
| if !ok { | ||
| return fmt.Errorf("internal error: remote transport is not an HTTPTransport") | ||
| } | ||
| httpT.SetStateless(true) | ||
| } |
There was a problem hiding this comment.
suggestion: SetStateless is called on the transport after construction but before Start. This works, but it's a mutation window — if calls get reordered, stateless mode silently doesn't apply. Consider passing it through as a transport constructor option (like WithRemoteBasePath) to make it impossible to miss. This follows the same setter pattern as SetOnHealthCheckFailed, so it's at least consistent with the current codebase — just noting the opportunity.
There was a problem hiding this comment.
Agreed about the mutation window. The setter pattern here matches SetOnHealthCheckFailed, SetOnUnauthorizedResponse, SetTokenSource, and SetRemoteURL — all called between construction and Start() in the same block. Moving one to a constructor option while leaving the others as setters would be inconsistent. I think the right approach is a broader refactor that converts all of these to constructor options at once, rather than doing it piecemeal.
If you like, I can do that refactor as part of this PR. Or we can leave that for a follow-up. Which would you prefer?
pkg/runner/runner.go
Outdated
| // Warn if stateless mode is set for a non-remote workload — it has no effect. | ||
| if r.Config.Stateless && r.Config.RemoteURL == "" { | ||
| slog.Warn("--stateless has no effect for local container workloads; use it only with remote URLs", | ||
| "server", r.Config.BaseName) | ||
| } |
There was a problem hiding this comment.
blocker: This is gated on RemoteURL != "", but a local container exposing stateless streamable-HTTP (not stdio) would have the same problem with GET-based health checks and GET forwarding. If a user runs thv run --transport streamable-http --stateless some-image, the proxy would still forward GETs to the container. The flag should work for any stateless streamable-HTTP server, not just remote ones.
There was a problem hiding this comment.
Fixed in 9109460. The stateless configuration now runs after the remote-URL block, so it applies to any HTTPTransport regardless of whether RemoteURL is set. Also updated the comments, flag text, and generated docs to remove the remote-only language.
There was a problem hiding this comment.
suggestion: The unit tests cover the pinger and method gate well, but an e2e test would give confidence this doesn't regress end-to-end. The existing e2e infrastructure already invokes thv run directly (e.g., health_check_zombie_test.go), so adding a test that stands up a minimal stateless HTTP server (POST-only, returns 405 on GET) and verifies the proxy returns 405 on GET and forwards POST correctly should be straightforward. Separately, adding a stateless mode to yardstick would make it easier to validate this locally — not a requirement for this PR, but it would help since there's no stateless MCP server fixture in the test infrastructure today.
There was a problem hiding this comment.
Added in 8a7267c. The test starts an in-process mock MCP server (POST-only, returns valid JSON-RPC), runs thv run --stateless <mockURL>/mcp, and verifies GET→405, POST→forwarded, DELETE→405.
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>
Verify that --stateless makes the proxy reject GET and DELETE with 405 and forward POST to the upstream MCP server. Uses an in-process mock server to avoid external dependencies. Signed-off-by: Greg Katz <gkatz@indeed.com>
Summary
--statelessflag tothv runthat: (1) returns 405 for GET, HEAD, and DELETE requests at the proxy instead of forwarding them, and (2) switches the health check to a POST-based JSON-RPC ping appropriate for servers without an SSE endpoint.Fixes #4514
Type of change
Test plan
task test)task lint-fix)Verified against a real stateless streamable-HTTP server: GET and HEAD requests to the proxy return 405 with
method not allowed: server is stateless (POST only); POST requests are forwarded to the remote server. E2E tests are not included — no stateless MCP server fixture exists in the test infrastructure.Changes
cmd/thv/app/run_flags.go--statelessCLI flagpkg/runner/config.goStateless booltoRunConfigpkg/runner/config_builder.goWithStateless()builder optionpkg/runner/runner.go--urlpkg/transport/http.goSetStateless()setter andbuildProxyOptions()helperpkg/transport/proxy/transparent/transparent_proxy.goWithStateless()option, method gate middleware, stateless pinger selectionpkg/transport/proxy/transparent/pinger.goStatelessMCPPingerfor POST-based health checkspkg/transport/proxy/transparent/pinger_test.goStatelessMCPPingerpkg/transport/proxy/transparent/method_gate_test.gostatelessMethodGateDoes this introduce a user-facing change?
Yes. Adds
--statelessflag tothv run. When set, the proxy returns 405 for GET, HEAD, and DELETE requests instead of forwarding them, enabling use with MCP servers that implement streamable-HTTP stateless mode.Generated with Claude Code