Skip to content

fix(proxy): implement session-aware streamable-http upstream client#56

Merged
olivrg merged 9 commits into
mainfrom
fix/upstream-streamable-http-client
Jun 10, 2026
Merged

fix(proxy): implement session-aware streamable-http upstream client#56
olivrg merged 9 commits into
mainfrom
fix/upstream-streamable-http-client

Conversation

@olivrg

@olivrg olivrg commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Description

Implements fixes from the recent Windows beta test - the highest-severity item: transport: streamable-http was a stateless JSON-RPC POST client, so spec-compliant session-enforcing servers (e.g. stock FastMCP, the official MCP SDK servers) rejected Helio's sessionless startup prime with HTTP 400 (infinite "unexpected shape" fail-closed loop) and upstream text/event-stream responses were rejected outright.

  • Replace the stateless POST client with a session-aware StreamableHttpForwarder per the two-regime session model: downstream traffic is transparent passthrough (each client's initialize forwarded verbatim, upstream Mcp-Session-Id relayed back, raw 404 passthrough so the client owns re-init), while Helio-internal sessionless traffic (the startup annotation prime) uses a lazily-initialized managed internal session with strict notifications/initialized, inflight coalescing, and a single re-init retry on HTTP 404. No global upstream session is ever shared across clients.
  • Validate JSON-RPC envelopes for both internal handshake steps (fail closed on HTTP-200 JSON-RPC errors instead of caching a poisoned session), use the upstream-negotiated protocol version, and scan handshake SSE responses incrementally with a read timeout and byte cap.
  • Parse both application/json and text/event-stream POST responses via a shared SSE parser (CRLF and field:value-without-space tolerant); send MCP-Protocol-Version on non-initialize upstream requests.
  • Classify annotation-prime failures (HTTP error / JSON-RPC error / non-JSON body / missing result.tools) so operators get actionable retry logs.
  • Add a real session-enforcing, SSE-replying upstream fixture (MCP SDK stateful transport) and an integration test reproducing the previously failing FastMCP flow end to end through the production wiring path.
  • Retire UpstreamForwarder to a deprecated compatibility alias of StreamableHttpForwarder; correct the docs claims and add CHANGELOG entries.

Closes #34
Closes #37
Closes #38
Closes #39
Closes #40
Closes #41
Closes #42
Closes #43

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Refactor (no functional changes)
  • Documentation
  • CI / build / tooling

Packages Affected

  • packages/proxy
  • packages/dashboard
  • packages/python-sdk
  • Root config / monorepo tooling
  • docs/
  • examples/

Checklist

  • I have read CONTRIBUTING.md
  • My code follows the existing style (ESLint + Prettier pass)
  • TypeScript strict mode — no any types or @ts-ignore without justification
  • I have added or updated tests for my changes
  • All CI checks pass (pnpm secrets:scan, pnpm docs:check:ci, pnpm audit --audit-level=high, pnpm build, pnpm lint, pnpm format:check, pnpm typecheck, pnpm test)
  • I have updated documentation if this changes user-facing behavior
  • Commit messages follow Conventional Commits (e.g. feat:, fix:, docs:)

How to Test

  1. Run a stock FastMCP server (mcp.run(transport="http")) or any MCP SDK server in stateful mode, point upstream.url at it with transport: streamable-http, run npx @gethelio/proxy start, and confirm the annotation cache primes ("N tools cached") instead of looping on HTTP 400 / "unexpected shape".
  2. Connect a real MCP client through the proxy: initialize returns the upstream Mcp-Session-Id, and subsequent tools/list / tools/call succeed over SSE responses.
  3. Stop the upstream and restart it (new session space): the prime path re-initializes once on 404; a downstream client sees the error and re-runs its own initialize.
  4. pnpm --filter @gethelio/proxy test — full suite (1384 tests) passes, including the session-enforcing SSE integration suite (integration-streamable-http-session.test.ts).
  5. pnpm --filter @gethelio/proxy benchmark — p99 within the <5ms target.

Additional Context

olivrg added 9 commits June 10, 2026 00:02
…pe error

Route primeAnnotationCache through forwardInternal (duck-typed) when available so
session-enforcing upstreams receive the warm-up request on the managed session.
Replace the opaque 'unexpected shape' fallback with classifyPrimeFailure, which
distinguishes HTTP error status, JSON-RPC error payloads, and missing result.tools.
The internal initialize handshake previously drained response bodies
unread, so an HTTP 200 carrying a JSON-RPC error could cache a
poisoned internal session, and the offered protocol version was used
even when the upstream negotiated a different one.

Parse and validate the JSON-RPC envelope for both internal handshake
steps (initialize and notifications/initialized), failing closed on
JSON-RPC errors over both application/json and text/event-stream
bodies. Capture the upstream-negotiated protocolVersion from the
initialize result and use it for the initialized notification and all
managed-session requests, falling back to the offered constant when
the upstream does not state one.

Scan notifications/initialized SSE responses incrementally with a
read timeout and a byte cap instead of buffering whole bodies, so a
never-closing or oversized stream cannot stall handshake error
classification.

Accept SSE field lines with and without a space after the colon in
the shared parser. Preserve an already-present mcp-protocol-version
request header in direct forwarder usage instead of overwriting it.

Add McpForwarderWithInternal to type the optional forwardInternal
contract and use it in the prime path instead of an ad-hoc cast.
Wire the session-enforcing integration test through the production
createForwarderFromConfig and GovernedForwarder path.
UpstreamForwarder was the stateless JSON-RPC POST client that the
session-aware StreamableHttpForwarder replaces. Keeping two divergent
implementations invites behavior drift: the old class still rejected
text/event-stream responses outright.

Turn UpstreamForwarder into a deprecated subclass alias of
StreamableHttpForwarder so existing imports keep working with the
fixed behavior, and mark it with @deprecated JSDoc pointing at the
replacement. Migrate the integration and e2e suites to construct
StreamableHttpForwarder directly, keeping forwarder.test.ts as
compatibility coverage for the alias. Suppress the deprecation lint
rule only on the deliberate public re-exports and the public-API
surface test, and assert the StreamableHttpForwarder export alongside
the alias. Record the deprecation in the changelog.
@olivrg olivrg merged commit 5b59820 into main Jun 10, 2026
3 checks passed
@olivrg olivrg deleted the fix/upstream-streamable-http-client branch June 10, 2026 14:21
@olivrg olivrg mentioned this pull request Jun 10, 2026
19 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment