refactor(examples): migrate all HTTP examples to streamable_http_app()#2291
refactor(examples): migrate all HTTP examples to streamable_http_app()#2291
Conversation
…xamples The low-level example servers use SseServerTransport and StreamableHTTPSessionManager directly, bypassing the auto-enable logic in sse_app()/streamable_http_app() that was added in v1.23.0 (GHSA-9h52-p55h-vw2f). Per the advisory guidance, low-level transport users should explicitly configure TransportSecuritySettings. These examples now demonstrate the correct pattern — the allowlist matches what the high-level API auto-configures for localhost binds. Github-Issue: #2269
There was a problem hiding this comment.
LGTM — mechanical documentation change that applies the same TransportSecuritySettings allowlist (matching what lowlevel/server.py auto-configures) across all low-level transport examples.
Extended reasoning...
Overview
This PR adds explicit TransportSecuritySettings to 9 example servers and 1 doc page that use SseServerTransport or StreamableHTTPSessionManager directly. Each change is identical: import TransportSecuritySettings and pass allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"] plus matching allowed_origins. The values exactly match what lowlevel/server.py:533-534 auto-configures for localhost binds.
Security risks
None — this change adds DNS rebinding protection rather than removing or weakening anything. The allowlist is appropriately restrictive (localhost-only) and correct for these examples, all of which bind uvicorn to 127.0.0.1. The PR correctly omits enable_dns_rebinding_protection=True since that is the field default (confirmed at transport_security.py:19).
Level of scrutiny
Low — this is examples and documentation only, with no library code changes. The pattern is mechanical and repeated identically across all 10 files. I verified that both SseServerTransport.__init__ (sse.py:78) and StreamableHTTPSessionManager.__init__ (streamable_http_manager.py:72) accept the security_settings parameter with the correct type.
Other factors
No CODEOWNERS file exists in the repo. The PR description reports passing tests (pytest tests/test_examples.py), type checks (pyright), and lint (ruff). No bugs were found by the automated review. The change brings the examples into compliance with guidance from GHSA-9h52-p55h-vw2f.
…app() simple-task, simple-task-interactive, and sse-polling-demo demonstrate tasks and SSE polling — transport wiring is incidental. Migrate them to Server.streamable_http_app(), which handles session manager creation, lifespan, routing, and DNS rebinding auto-enable in one call. simple-streamablehttp and simple-streamablehttp-stateless stay on the low-level API since demonstrating manual StreamableHTTPSessionManager wiring is their purpose.
There was a problem hiding this comment.
LGTM — examples-only change applying a consistent mechanical pattern, with the refactored task/polling servers correctly delegating to streamable_http_app()'s auto-enable logic.
Extended reasoning...
Overview
This PR touches 9 example servers + 1 docs page (no src/ changes). Two mechanical patterns are applied:
-
Six files: add a
security_settings=TransportSecuritySettings(...)kwarg to existingSseServerTransport(...)/StreamableHTTPSessionManager(...)constructor calls. The allowlist is byte-identical to whatlowlevel/server.py:530-535auto-configures. I verified the kwarg exists on both constructors (sse.py:78,streamable_http_manager.py:72) and thatenable_dns_rebinding_protectiondefaults toTrue(transport_security.py:19), so omitting it is correct. -
Three files (
simple-task,simple-task-interactive,sse-polling-demo): replace ~15 lines ofStreamableHTTPSessionManager+Starlette+ lifespan boilerplate withserver.streamable_http_app(). I verified this method acceptsevent_store,retry_interval, anddebug(lines 518–526), sosse-polling-demo's refactor preserves all behavior. The auto-enable path at line 530 fires becausehostdefaults to"127.0.0.1".
Security risks
None — this PR only adds security gating to localhost-bound example servers. The allowlists are strictly localhost-scoped. There is no way for this change to widen the attack surface; at worst it would overly restrict (which the probe results disprove for the 6 unchanged-architecture servers).
Level of scrutiny
Low. These are demo/example servers, not library code. Every change is either (a) passing a hardening kwarg with the same value the high-level API already uses, or (b) replacing bespoke Starlette wiring with the canonical helper. No logic was invented; all building blocks already exist and are tested.
Other factors
- No CODEOWNERS file in the repo.
- No prior reviews or comments to address.
- Static checks (pytest, pyright, ruff) reported green in the PR description.
- The single nit flagged (inline comment) concerns PR-description accuracy for the 3 refactored servers' probe results — a
RoutevsMountrouting detail that would yield 307 redirects on the test script's trailing-slash/mcp/path. The code itself is correct:streamable_http_app()registersRoute("/mcp", ...)(line 598–601) and the security gate still fires on the canonical/mcppath. This does not affect mergeability.
examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py
Outdated
Show resolved
Hide resolved
examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py
Outdated
Show resolved
Hide resolved
examples/servers/simple-pagination/mcp_simple_pagination/server.py
Outdated
Show resolved
Hide resolved
Per review from pcarleton on #2291: - simple-streamablehttp, simple-streamablehttp-stateless: use app.streamable_http_app() with CORS wrapped around the returned Starlette app. Removes manual StreamableHTTPSessionManager wiring. - simple-tool, simple-prompt, simple-resource, simple-pagination: replace --transport sse (legacy) with --transport streamable-http using app.streamable_http_app(). READMEs updated to match. - docs/experimental/tasks-server.md: use server.streamable_http_app() instead of manual wiring. All 9 examples now get DNS rebinding protection via the auto-enable in streamable_http_app() — zero explicit TransportSecuritySettings needed. Verified live: 45/45 probes pass (421 for bad Host, 403 for bad Origin).
examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py
Show resolved
Hide resolved
examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py
Show resolved
Hide resolved
examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py
Show resolved
Hide resolved
streamable_http_app() enforces localhost-only Origin by default, so the 'Allow all origins' CORS comment was misleading — preflight would succeed but the actual POST would return 403 for non-localhost origins. Also removed README bullets that described the manual lifespan/task-group wiring that no longer exists in these examples.
Migrates all 9 HTTP example servers (plus
docs/experimental/tasks-server.md) to useServer.streamable_http_app()instead of manually wiringSseServerTransport/StreamableHTTPSessionManager+ lifespan + Starlette routes.Motivation and Context
GHSA-9h52-p55h-vw2f added DNS rebinding auto-enable for localhost binds, but only at the high-level API layer. The advisory notes:
Our examples were using those low-level APIs without explicit security settings — so they ran unprotected despite binding to
127.0.0.1. Rather than demo the footgun (passing explicit allowlists everywhere), this PR migrates every example to the high-level API where auto-enable lives.What changed
simple-streamablehttp,-statelessStreamableHTTPSessionManager+@asynccontextmanagerlifespan +Starlette(routes=[Mount])app.streamable_http_app(...). CORS still wraps the returned app.simple-task,-task-interactive,sse-polling-demoserver.streamable_http_app(...)simple-tool,-prompt,-resource,-pagination--transport ssebranch with 30 lines ofSseServerTransportwiring--transport streamable-httpbranch:uvicorn.run(app.streamable_http_app(), ...)docs/experimental/tasks-server.mdserver.streamable_http_app()Net: −289 lines. Zero
TransportSecuritySettingsreferences remain inexamples/.Related: #2269 (closed — already addressed in v1.23.0 for high-level API), #2275/#2287 (closed — naive middleware flip would reject all requests with empty allowlists).
How Has This Been Tested?
Static checks
uv run --frozen pytest tests/test_examples.py— 45 passeduv run --frozen pyright examples/servers/...— 0 errorsuv run --frozen ruff check examples/servers/— all checks passedLive end-to-end verification — 45/45 probes passed
Each server started, probed with curl, killed. All 9 now use
streamable_http_app()withRoute("/mcp", ...)(no trailing slash).127.0.0.1:PORT400{}as invalid JSON-RPC)localhost:PORT400localhost:*allowlist entry worksevil.com421127.0.0.1:PORThttp://evil.com403127.0.0.1:PORThttp://localhost:PORT400Full results (45/45)
Test script
Breaking Changes
None — examples only.
Types of changes
Checklist