Skip to content

feat: add Streamable HTTP transport to MCP host - #164

Open
Flames4fun wants to merge 5 commits into
OWASP:mainfrom
Flames4fun:feature/mcp-streamable-http
Open

Flames4fun wants to merge 5 commits into
OWASP:mainfrom
Flames4fun:feature/mcp-streamable-http

Conversation

@Flames4fun

Copy link
Copy Markdown
Contributor

Fixes #161

Describe the changes you have made in this PR -

This PR adds unauthenticated Streamable HTTP transport support to the real MCP host runtime, alongside the existing stdio transport.

I wanted to keep this change narrow and reviewable: the goal is not to redesign the MCP host, but to add a second transport that converges into the same session lifecycle, trace format, error model, and cleanup guarantees already used by stdio.

Design approach

Before implementing the transport, I considered three possible approaches.

Option A — Legacy API only

Use streamablehttp_client, which is available at the project’s minimum supported MCP SDK version (1.12.4).

This would have been the smallest implementation and would have covered the complete declared dependency range. However, it would also introduce a new feature using an API that is already deprecated in newer MCP v1 releases.

Option B — Modern API only

Use the newer streamable_http_client API and a host-managed httpx.AsyncClient.

This provides the cleanest current API and clearer HTTP client ownership, but it is not available in MCP 1.12.4. Choosing it exclusively would either break the project’s published compatibility range or require raising the minimum MCP version as part of this PR.

Option C — Modern-first compatibility adapter

Prefer streamable_http_client when the installed SDK exposes it, and use streamablehttp_client only when the modern symbol is absent.

I chose this approach because it is the only one that satisfies both sides of the project’s current contract:

  • It supports the declared mcp>=1.12.4,<2 range.
  • It uses the current SDK API whenever possible.
  • It avoids version-string checks.
  • It keeps the API differences isolated at the transport boundary.
  • It provides a straightforward path to remove the legacy branch when the project eventually raises its minimum MCP version.

The fallback is based strictly on symbol availability. A failed modern connection is treated as a real operational failure and is never retried through the legacy API, since doing so could hide defects or duplicate connection side effects.

Runtime configuration

The MCP runtime configuration now:

  • Accepts transport: streamable_http.
  • Requires an HTTP or HTTPS url.
  • Accepts optional static headers.
  • Preserves the existing per-server timeout_seconds.
  • Supports stdio and Streamable HTTP servers in the same runtime configuration.
  • Keeps transport explicit, preserving the parser’s existing behavior.
  • Rejects transport-specific fields instead of accepting and silently ignoring them.

For example, stdio continues to require command, while Streamable HTTP requires url and rejects stdio-only fields such as command, args, env, and cwd.

The URL and configured headers are treated as potentially sensitive configuration and are excluded from the configuration representation.

MCP SDK compatibility

The host now:

  • Prefers the modern streamable_http_client API when available.
  • Uses the legacy streamablehttp_client API only when the modern symbol is absent.
  • Detects capabilities by symbol presence rather than package-version comparisons.
  • Normalizes both SDK transport results into the same internal read/write stream contract.
  • Passes those streams into the existing shared ClientSession lifecycle.
  • Preserves stdio functionality when the optional HTTP SDK surface is missing or broken.
  • Produces a clear HTTP-specific AdapterError only when Streamable HTTP is explicitly requested.

A focused Python 3.11 compatibility matrix validates both boundaries of the declared MCP v1 range:

  • MCP 1.12.4
  • Latest stable MCP v1 allowed by mcp>=1.12.4,<2

Transport ownership and lifecycle

Transport-specific behavior ends after obtaining the read and write streams.

From that point onward, both transports use the same flow:

ClientSession → initialize → discover tools → execute tool calls → close

For the modern API:

  • The host creates and owns one httpx.AsyncClient.
  • The HTTP client is entered before the MCP transport.
  • The MCP transport is closed before the HTTP client.

For the legacy API:

  • The legacy SDK transport owns its internal HTTP client.
  • The host provides a small HTTPX factory to enforce the required redirect policy.
  • The host does not create or close a duplicate outer HTTP client.

Partial failures during HTTP client creation, transport entry, stream normalization, session entry, initialization, tool discovery, and tool execution all unwind through the existing async resource stack.

Host behavior and trace parity

The host now dispatches through a single transport boundary based on server_config.transport:

  • stdio uses the existing stdio connector.
  • streamable_http uses the new HTTP connector.
  • Unsupported transports produce a defensive AdapterError.

Both transports produce the same canonical trace containers:

  • mcp_servers
  • mcp_tool_calls
  • mcp_events

Successful runs preserve the existing lifecycle sequence:

mcp_connection_initialized → mcp_tools_discovered → mcp_tool_result → mcp_connection_closed

The existing stdio metadata remains unchanged. HTTP metadata uses the configured server ID and negotiated MCP server information without introducing an artificial command field or exposing the endpoint URL.

Security and error handling

Several defensive decisions were applied deliberately:

  • Automatic HTTP redirects are disabled in both SDK paths.
  • Users must configure the final MCP endpoint URL.
  • This prevents custom headers such as X-Api-Key from being forwarded to an unintended redirected origin.
  • Protocol-owned and HTTP framing headers cannot be overridden through runtime configuration.
  • URLs, query parameters, and configured header values are omitted from canonical MCP trace metadata.
  • HTTP operational errors use bounded, operation-oriented AdapterError messages rather than interpolating raw HTTP exceptions.
  • Stdio retains its existing error behavior.

Real MCP/AnyIO testing also revealed that cleanup could wrap an already-safe AdapterError inside an ExceptionGroup.

The host now unwraps that group only when its complete recursive leaf set contains exactly one unambiguous AdapterError.

It deliberately does not collapse:

  • Mixed exception groups.
  • Groups containing multiple AdapterError instances.
  • Cancellation.
  • KeyboardInterrupt.
  • SystemExit.
  • Unrelated implementation failures.

This preserves useful failure information instead of selecting an arbitrary exception and potentially hiding a real cleanup defect.

Tests and validation

The implementation includes focused coverage for:

  • Modern and legacy SDK selection.
  • Modern-first precedence.
  • Absence of operational fallback.
  • Transport dispatch.
  • Mixed stdio and HTTP configurations.
  • URL and header validation.
  • HTTP client ownership.
  • Redirect policy.
  • Timeout propagation.
  • Stream normalization.
  • Initialization and tool discovery.
  • Canonical trace parity.
  • Cleanup after partial failures.
  • Cancellation behavior.
  • Secret redaction.
  • Safe ExceptionGroup normalization.
  • Existing stdio behavior and error wording.

Real SDK and integration validation included:

  • MCP 1.12.4.
  • Latest stable MCP v1.
  • Real modern Streamable HTTP loopback execution.
  • Real legacy Streamable HTTP loopback execution.
  • A real CLI invocation through --mcp-host-target.
  • A real inaccessible endpoint to verify the public AdapterError boundary.
  • Real stdio regression coverage.

Validation results:

  • Full local suite: 526 passed, 1 skipped
  • Minimum MCP environment: 204 passed, 1 skipped
  • Latest-v1 environment: 204 passed, 1 skipped
  • Ordinary environment without MCP: 520 passed, 3 skipped
  • Ruff on changed files: passed
  • mypy: passed with no issues
  • pip check: passed in all isolated environments
  • Workflow YAML parsing: passed
  • git diff --check: passed

Local validation used Python 3.12 on Windows. GitHub Actions is configured to validate Python 3.11 on Ubuntu.

The latest-v1 matrix entry intentionally floats within mcp>=1.12.4,<2, so it can detect future stable MCP v1 releases that become incompatible while still satisfying the project’s declared dependency range.

Documentation

This PR also:

  • Documents the distinction between --mcp-target and --mcp-host-target.
  • Adds valid stdio and Streamable HTTP runtime configuration examples.
  • Documents transport-specific fields.
  • Documents the no-redirect policy.
  • Documents static header behavior and authentication limitations.
  • Documents timeout and canonical trace behavior.
  • Removes obsolete stdio-only wording.
  • Updates the runner docstring to use transport-neutral terminology.
  • Adds the new user-visible configuration surface under [Unreleased] in CHANGELOG.md.

Out of scope

The PR intentionally does not include:

  • OAuth
  • Authorization discovery or token refresh
  • Authenticated transport lifecycle
  • Configured legacy SSE transport
  • MCP resources
  • MCP prompts
  • Sampling
  • Roots or default-deny roots
  • Retries
  • SSRF or endpoint allowlist policy
  • MCP v2 behavior

Those remain follow-up work under the broader MCP host roadmap.


Code Understanding and AI Usage

Did you use AI assistance (ChatGPT, Claude, Copilot, etc.) to write any part of this code or documentation?

  • No, I wrote all the code myself
  • Yes, I used AI assistance

If you used AI assistance, briefly describe:

  • Tool(s) used: OpenAI Codex and ChatGPT.
  • Parts of the contribution that were AI-assisted: Implementation planning, compatibility analysis, regression-test design, documentation and planning.
  • How I reviewed the output: I reviewed the complete diff and verified each behavioral change against issue Add Streamable HTTP transport to the MCP host (stdio parity, unauthenticated MVP) #161, the repository’s existing architecture, the supported MCP SDK range, and the contribution guidelines. I traced the implementation through the transport lifecycle, resource ownership, error boundaries, and canonical traces, and reviewed the final code to ensure the feature remained within scope.
  • Tests or checks I ran: Full pytest suite, isolated MCP 1.12.4 and latest-v1 compatibility suites, ordinary no-MCP suite, real modern and legacy loopback simulations, real CLI execution, inaccessible-endpoint error replay, stdio regression tests, Ruff, mypy, pip check, documentation example validation, workflow YAML parsing, and git diff --check.

Checklist before requesting a review

  • I have added a proper PR title and linked the issue
  • I have performed a self-review of my code
  • I can explain the purpose of every documentation, function, class, and logic block I added
  • I understand why my changes work and have tested them thoroughly
  • I have considered potential edge cases and how the implementation handles them
  • If it is a core feature, I have added thorough tests
  • My code follows the project’s style guidelines and conventions
  • I added an entry under [Unreleased] in CHANGELOG.md

@Flames4fun
Flames4fun requested a review from mertsatilmaz as a code owner July 27, 2026 21:33

@ossumpossum ossumpossum left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approve with nits. The transport is well-scoped and converges cleanly into the existing stdio session/trace/error model, and the security posture is careful: redaction, redirects off, reserved-header rejection. Fallback selection and the "no operational fallback" guarantee hold and are well tested. Two things are worth closing before merge: a discrepancy between the description and the exception-group code (F1), and a gap between the "real loopback" claims and what actually ships as a reproducible test. Neither blocks correctness.

Verification basis: I read the full diff (2801/-102, 11 files) and both test modules, reproduced F1 directly against the function on the PR head (14b726a), and confirmed CI is green on run 30307827273, including both compatibility boundaries (minimum and latest-v1). I did not run the full suite locally.

What holds up

  1. Modern/legacy selection is correct across the declared range. _discover_streamable_http_clients pulls both symbols via getattr(..., None) guarded by _optional_sdk_callable, and _select_streamable_http_client prefers streamable_http_client, falling back to streamablehttp_client only on absence. Selection is by symbol presence, not version strings, which is correct for mcp>=1.12.4,<2. The load-bearing empirical claim (min SDK exposes only legacy, latest exposes modern) is asserted by the new mcp-sdk-compatibility matrix (expected_api: legacy/modern), and both entries are green in CI. That job is the real guarantor.

  2. "Never retry legacy after a modern failure" holds. _open_streamable_http_transport selects once, then dispatches to the modern or legacy context; modern failures re-raise AdapterError ... from None, and the legacy client is never touched. Pinned by test_selected_modern_contract_does_not_operationally_fallback_to_legacy and test_modern_operational_failure_never_falls_back_and_redacts_http_details (asserts rig.legacy_calls == []).

  3. Redaction is the strongest part of the change. MCPServerConfig.url/.headers are field(repr=False) (test_streamable_http_repr_omits_url_and_headers); _transport_metadata returns {} for streamable_http, so connection and close events carry no endpoint or credential material; transport, session, and tool errors do not interpolate the raw exception and sever __cause__ via from None, so the wrapped SDK exception (which carries the URL and headers in its repr) is unreachable from the trace (test_http_session_errors_identify_operation_without_exposing_raw_exception, test_http_cleanup_exception_group_preserves_single_safe_adapter_error). One invariant for follow-ups: this depends on nothing doing str(url) or attribute access on server_config into a log line. repr is safe; direct field access is not. A one-line comment on the config fields would keep a future contributor from reintroducing a leak.

  4. Trace parity holds on success and on the tool-error path. Both transports route through _connect_mcp_session, so the sequence mcp_connection_initialized → mcp_tools_discovered → mcp_tool_result → mcp_connection_closed is produced identically; asserted in test_streamable_http_modern_path_has_canonical_trace_and_owned_cleanup, and preserved on a raising tool call in test_http_tool_error_uses_shared_event_path_but_redacts_transport_details. The stdio and HTTP metadata sets differ only by command (stdio) versus nothing (HTTP), which is intended.

  5. Redirect policy is a real mitigation. follow_redirects=False is enforced on both paths: directly on the host-owned client, and forced in _legacy_httpx_client_factory even if the caller passed True (test_legacy_httpx_factory_preserves_kwargs_without_mutating_caller_values). This closes the confused-deputy vector where a configured Authorization or X-Api-Key would otherwise follow a 3xx to an unintended origin.

Findings I'd like addressed

F1 — Exception-group collapse is broader than the description claims (correctness). _single_adapter_error_from_exception_group walks the group's leaves and returns the sole AdapterError when len(adapter_errors) == 1. It only bails (return None) on a non-Exception leaf (Cancelled/KeyboardInterrupt/SystemExit) or on multiple AdapterErrors. A leaf that is an ordinary Exception but not an AdapterError, such as a RuntimeError raised during cleanup, is neither collected nor treated as disqualifying. So a group of [AdapterError, RuntimeError] collapses to the AdapterError, and because the caller does raise adapter_error from None, the RuntimeError is dropped.

I reproduced this against the function on the PR head:

from agent_harness.mcp_host import _single_adapter_error_from_exception_group
from agent_harness.adapters import AdapterError
grp = BaseExceptionGroup("cleanup", [AdapterError("adapter failed"), RuntimeError("unexpected cleanup failure")])
_single_adapter_error_from_exception_group(grp)
# -> AdapterError('adapter failed')   # the RuntimeError is dropped

Controls behave as expected: [AdapterError, AdapterError] returns None, and [AdapterError] returns the AdapterError. AdapterError subclasses RuntimeError, but a plain RuntimeError is still not an AdapterError, so it slips through.

This contradicts the description, which states the host "deliberately does not collapse ... unrelated implementation failures." Two options: tighten the guard so it returns None when any leaf is an Exception that is not an AdapterError (collapse only when every leaf is an AdapterError and exactly one exists); or soften the description to match the behavior. I'd tighten it, since masking an unexpected cleanup failure behind a possibly-secondary AdapterError is the outcome the rest of this function avoids. A regression test for the [AdapterError, RuntimeError] case, alongside the existing test_exception_group_with_cancellation_is_not_converted_to_adapter_error, would fail today and pass once F1 is resolved.

F2 — SSRF/endpoint reachability is out of scope but should be named in the security notes. _parse_streamable_http_url does solid input hygiene (scheme allowlist, no credentials, no fragment, no control chars), but by design it accepts loopback, RFC1918, and link-local hosts: test_parse_streamable_http_accepts_supported_url_forms_without_rewriting accepts http://192.168.1.25/mcp and http://[::1]:8000/mcp. An operator-supplied url pointing at http://169.254.169.254/… is reachable. This is bounded, since the runtime config is trusted and local, and the design already lists origin allowlists as future work. I'm not asking for a policy engine, only one line in the adapter docs' security section stating that the transport performs no SSRF or allowlist filtering, so users don't infer safety from the redirect and redaction hardening that is present.

Test gaps (non-blocking)

The suite is thorough on branch coverage but leans on deterministic in-process fakes. test_mcp_sdk_compat.py uses the real mcp/httpx but monkeypatches socket.create_connection and httpx.AsyncClient.send to hard-fail, so it verifies the real client accepts the production argument shape and returns an async context manager (a genuine signature check) but never drives a real initialize → list_tools → call_tool. The description's "real modern/legacy loopback execution" is asserted in prose, not shipped as a reproducible test. One opt-in integration-marked test against a localhost FastMCP streamable-http server would anchor the fakes to real SDK behavior and close the untested _normalize_streamable_http_transport_result real-object path; keep default CI hermetic. Minor: a one-line comment on the filesystem_server.py get_type_hints/re-decoration (why it is needed for the compat matrix), and on the latest-v1 canary intent (a red there on a future mcp<2 rename isn't necessarily the contributor's fault), would help the next reader.

Resolve F1 (or reword the description), add the one integration test, and note the SSRF boundary in the docs, and I'm happy to approve.

Disclosure: this review was prepared with AI assistance (Claude Code). I read the diff and both test modules, reproduced the F1 behavior against the PR head myself, and am accountable for its contents.

@Flames4fun

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review.

I independently reproduced F1 and agree that the current helper can collapse a mixed [AdapterError, RuntimeError] group and hide the unrelated cleanup failure. I’ll tighten the condition so that collapsing only occurs when the complete recursive leaf set contains exactly one AdapterError, and I’ll add a regression test for the mixed-group case.

I’ll also add an explicit user-facing note that the transport does not perform SSRF filtering or endpoint allowlisting and that private, loopback, and link-local endpoints are not blocked.

Regarding the loopback test, issue #161 explicitly asks for focused transport-level coverage that does not require a live network service. The modern and legacy loopback executions listed in the PR description were manual local validation rather than committed CI coverage. I’ll clarify that distinction in the description. I’d prefer to keep a live FastMCP integration test optional or as follow-up work unless the code owner @mertsatilmaz wants it included in this PR.

@ossumpossum

Copy link
Copy Markdown

Thanks, that all sounds right.

Your F1 framing (collapse only when the complete recursive leaf set contains exactly one AdapterError) is it, and the mixed-group regression test is the piece that locks it in.

On the loopback test: fair. Clarifying that the modern/legacy runs were manual validation rather than committed coverage resolves the over-claim I was flagging. The mcp-sdk-compatibility job already pins the real SDK's construction-time contract across the version range, so the only uncovered surface is the live initialize → list_tools → call_tool exchange. Keeping a FastMCP integration test as optional follow-up is reasonable given #161's scope, so I'll leave that call to @mertsatilmaz. If it does land later, marking it opt-in keeps default CI hermetic.

Appreciate the quick turnaround.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Streamable HTTP transport to the MCP host (stdio parity, unauthenticated MVP)

2 participants