Uh oh!
There was an error while loading. Please reload this page.
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.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
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).
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
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