Skip to content

feat: add streamable HTTP transport, and fix the panic that makes HTTP transports unusable with --argo-http1 - #215

Open
ngkaizhe wants to merge 6 commits into
pipekit:mainfrom
ngkaizhe:feat/streamable-http-transport
Open

feat: add streamable HTTP transport, and fix the panic that makes HTTP transports unusable with --argo-http1#215
ngkaizhe wants to merge 6 commits into
pipekit:mainfrom
ngkaizhe:feat/streamable-http-transport

Conversation

@ngkaizhe

Copy link
Copy Markdown
Contributor

Summary

Two related changes for running this server remotely. The fix comes first because without it the new transport — and the existing one — cannot serve a single tool call against an Argo Server over HTTP/1.1.

1. Fix: HTTP transports lose the Argo client's context metadata

Under an HTTP transport, tool calls run on a context derived from the incoming HTTP request. The Argo client's metadata — its logger, the Kubernetes client — lives on the process-wide context main builds and installs via argoClient.Context(). Under stdio these are the same context, so the gap never showed.

With --argo-http1 it is fatal. http1.Facade.do calls logging.RequireLoggerFromContext, which panics on a context without a logger rather than returning an error, and the panic takes the process down.

Reproduced against released v0.4.0 (so this is not introduced by this PR):

docker run ghcr.io/pipekit/mcp-for-argo-workflows:0.4.0 \
  --transport http --http-addr 0.0.0.0:8080 \
  --argo-server argo.example.com:443 --argo-http1 --argo-token "Bearer dummy"

Handshake succeeds, then the first tools/call gives:

no logger in context Call stack:
github.com/argoproj/argo-workflows/v4/util/logging.RequireLoggerFromContext(...)
github.com/argoproj/argo-workflows/v4/pkg/apiclient/http1.Facade.do(...)
...
panic: logger not found in context

and the container exits (2). gRPC mode does not panic, so this is specific to the HTTP/1.1 REST client — which is exactly the mode you need when Argo Server sits behind an nginx ingress.

The fix makes each request's context fall back to the server context for value lookups only, keeping the request's own cancellation and deadline. It repairs the existing http transport as much as the new one.

Happy to split this into its own PR if you would rather land it separately — it stands alone.

2. Feature: --transport streamable-http

Adds the transport that supersedes HTTP+SSE in the MCP spec. http keeps serving HTTP+SSE unchanged, so nothing breaks for current users.

Why it matters for remote deployments: HTTP+SSE routes every server→client message through one long-lived GET stream. Behind a proxy that closes idle streams — Envoy's stream_idle_timeout defaults to 300s — losing that stream strands the session, because later responses have nowhere to go. Streamable HTTP gives each POST its own response stream, so the same cut costs nothing.

Two deployment details, both learned from putting this behind a gateway:

  • Mounted on /mcp alone, not on the root. The SSE handler answers on every path (GET /healthz returns an SSE endpoint event, not a health check), so a proxy cannot publish the MCP endpoint without publishing everything else.
  • /healthz returns a plain 200. A container probe needs a target that means what it says; GET /mcp without a session is a client error, not a sign the server is unhealthy.

Changes

  • internal/server/http.gowithServerValues middleware (the fix); RunStreamableHTTP; shared serve helper for both transports
  • internal/config/config.gostreamable-http transport value, IsStreamableHTTPTransport, UsesHTTPListener, validation
  • cmd/mcp-for-argo-workflows/main.go — dispatch on the configured transport
  • README.md — both remote transports, the /mcp client URL, the bind-address note
  • Tests — context fallback/precedence/cancellation; health, 404-on-other-paths, handshake-issues-a-session, shutdown, bad address; transport validation and MCP_TRANSPORT parsing

Test plan

gofmt -s -l, go vet ./..., golangci-lint v2.12.2 run ./... (0 issues), and go test -race ./internal/... ./pkg/... all clean — run against exactly the commits in this PR, not a local variant.

End-to-end against a real Argo Workflows v3.7 server behind an nginx ingress, with --argo-http1:

check result
GET /healthz 200
GET / /sse /metrics 404
initialize on POST /mcp 200, Mcp-Session-Id issued
tools/call list_workflows returns workflows; 0 panics, process stays up
same tool call on --transport http (SSE) also works now; panicked before the fix

Notes

I did not add a --read-only-style flag for the streamable handler's Stateless option. Stateless mode would drop the single-replica constraint for horizontally scaled deployments and might be worth a follow-up, but it changes server→client request semantics, so it felt out of scope here.

Sync fork with upstream main
Under an HTTP transport, tool calls run on a context derived from the
incoming HTTP request, while the Argo client's metadata (its logger, the
Kubernetes client) lives on the process-wide context built in main. Under
stdio the two are the same context, so this never surfaced.

With --argo-http1 the mismatch is fatal: the Argo REST facade calls
logging.RequireLoggerFromContext, which panics on a context without a
logger instead of returning an error, and the panic takes the whole
process down. Any tool call against an Argo Server over HTTP/1.1 kills the
server, so a remote deployment behind a reverse proxy cannot serve a
single request.

Make each request's context fall back to the server context for value
lookups while keeping its own cancellation and deadline.
Add --transport streamable-http (MCP_TRANSPORT=streamable-http), the
transport that supersedes HTTP+SSE in the MCP specification. The existing
"http" value keeps serving HTTP+SSE, so nothing changes for current users.

HTTP+SSE routes every server-to-client message through one long-lived GET
stream. Behind a reverse proxy that closes idle streams - Envoy's
stream_idle_timeout defaults to 300s - losing that stream strands the
session, because later responses have nowhere to go. Streamable HTTP gives
each POST its own response stream, so the same cut costs nothing.

Unlike the SSE handler, which answers on every path, the streamable
handler is mounted on /mcp alone, with a plain liveness endpoint on
/healthz. A proxy can then publish the MCP endpoint without publishing
anything else the process serves, and a container probe has a target that
means what it says: GET /mcp without a session is a client error, not a
sign the server is unhealthy.
Wire the new transport through configuration: --transport streamable-http
and MCP_TRANSPORT=streamable-http now start it. The "http" value keeps
serving HTTP+SSE, so existing deployments are unaffected.

Validation accepts all three modes and requires an address for either
HTTP-based one. Multi-context stays stdio-only, as before.
Dispatch on the configured transport so streamable-http starts the new
server, and log the endpoint path at startup.

Document the transport alongside HTTP+SSE: what each one costs behind a
reverse proxy, the /mcp URL clients need, and the reminder to state the
bind address explicitly in a container.
Cover both remote transports: what each costs behind a reverse proxy that
closes idle streams, the /mcp URL clients need, and the reminder to state
the bind address explicitly in a container.
@Joibel

Joibel commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Happy to split this into its own PR if you would rather land it separately — it stands alone.

That would always be my preference. Easier to review smaller standalone PRs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants