Deprecate the legacy HTTP+SSE transport - #3204
Conversation
📚 Documentation preview
|
The HTTP+SSE transport was superseded by Streamable HTTP in the 2025-03-26 spec revision and is formally deprecated under SEP-2596. Mark every public entry point with typing_extensions.deprecated so type checkers flag it and a runtime MCPDeprecationWarning fires: sse_client, SseServerParameters, SseServerTransport, MCPServer.sse_app / run_sse_async, and the run(transport="sse") overload. Nested constructions suppress the inner warning so callers see exactly one. Also: mcp run --transport help text drops sse, python -m mcp.client connects with Streamable HTTP, and the url_elicitation client snippet moves to Streamable HTTP. Docs and migration guide updated. No-Verification-Needed: user requested immediate push, verification explicitly waived
There was a problem hiding this comment.
3 issues found and verified against the latest diff
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/mcp/client/session_group.py">
<violation number="1" location="src/mcp/client/session_group.py:331">
P2: Session-group SSE connections still emit both deprecation warnings: one constructing `SseServerParameters` and one entering `sse_client`. Keep the targeted warning suppression active through `enter_async_context(client)` (or make `sse_client` warn when its context manager is constructed).</violation>
</file>
<file name="examples/snippets/clients/url_elicitation_client.py">
<violation number="1" location="examples/snippets/clients/url_elicitation_client.py:289">
P3: When the initial Streamable HTTP connection is refused, the recovery message still tells users to start the server with `sse`. That starts the legacy transport instead of the `/mcp` endpoint this client now targets, so following the error guidance leaves the retry unable to connect. Updating the fallback (and the module usage text) to `streamable-http` would keep the example runnable.</violation>
</file>
<file name="pyproject.toml">
<violation number="1" location="pyproject.toml:271">
P2: The suite-wide ignore masks this SDK's new deprecation warning at every call site, so future tests can accidentally introduce legacy HTTP+SSE usage without failing. The existing legacy-transport tests can keep a module- or test-level `filterwarnings` marker instead, which preserves the warning-as-error guard for all other code paths.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| with warnings.catch_warnings(): | ||
| warnings.simplefilter("ignore", MCPDeprecationWarning) | ||
| client = sse_client( # pyright: ignore[reportDeprecated] | ||
| url=server_params.url, | ||
| headers=server_params.headers, | ||
| timeout=server_params.timeout, | ||
| sse_read_timeout=server_params.sse_read_timeout, | ||
| ) | ||
| read, write = await session_stack.enter_async_context(client) |
There was a problem hiding this comment.
P2: Session-group SSE connections still emit both deprecation warnings: one constructing SseServerParameters and one entering sse_client. Keep the targeted warning suppression active through enter_async_context(client) (or make sse_client warn when its context manager is constructed).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/client/session_group.py, line 331:
<comment>Session-group SSE connections still emit both deprecation warnings: one constructing `SseServerParameters` and one entering `sse_client`. Keep the targeted warning suppression active through `enter_async_context(client)` (or make `sse_client` warn when its context manager is constructed).</comment>
<file context>
@@ -313,13 +325,17 @@ async def _establish_session(
+ elif isinstance(server_params, SseServerParameters): # pyright: ignore[reportDeprecated]
+ # The caller already saw the deprecation warning when they built
+ # SseServerParameters; suppress the nested one from sse_client.
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", MCPDeprecationWarning)
+ client = sse_client( # pyright: ignore[reportDeprecated]
</file context>
| with warnings.catch_warnings(): | |
| warnings.simplefilter("ignore", MCPDeprecationWarning) | |
| client = sse_client( # pyright: ignore[reportDeprecated] | |
| url=server_params.url, | |
| headers=server_params.headers, | |
| timeout=server_params.timeout, | |
| sse_read_timeout=server_params.sse_read_timeout, | |
| ) | |
| read, write = await session_stack.enter_async_context(client) | |
| with warnings.catch_warnings(): | |
| warnings.simplefilter("ignore", MCPDeprecationWarning) | |
| client = sse_client( # pyright: ignore[reportDeprecated] | |
| url=server_params.url, | |
| headers=server_params.headers, | |
| timeout=server_params.timeout, | |
| sse_read_timeout=server_params.sse_read_timeout, | |
| ) | |
| read, write = await session_stack.enter_async_context(client) |
| "ignore:ping is removed as of 2026-07-28.*:mcp.MCPDeprecationWarning", | ||
| # SEP-2596 deprecates the HTTP+SSE transport; the SDK still ships it for | ||
| # servers/clients that haven't migrated, and the tests still exercise it. | ||
| "ignore:.*HTTP\\+SSE transport is deprecated as of 2025-03-26.*:mcp.MCPDeprecationWarning", |
There was a problem hiding this comment.
P2: The suite-wide ignore masks this SDK's new deprecation warning at every call site, so future tests can accidentally introduce legacy HTTP+SSE usage without failing. The existing legacy-transport tests can keep a module- or test-level filterwarnings marker instead, which preserves the warning-as-error guard for all other code paths.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At pyproject.toml, line 271:
<comment>The suite-wide ignore masks this SDK's new deprecation warning at every call site, so future tests can accidentally introduce legacy HTTP+SSE usage without failing. The existing legacy-transport tests can keep a module- or test-level `filterwarnings` marker instead, which preserves the warning-as-error guard for all other code paths.</comment>
<file context>
@@ -266,6 +266,9 @@ filterwarnings = [
"ignore:ping is removed as of 2026-07-28.*:mcp.MCPDeprecationWarning",
+ # SEP-2596 deprecates the HTTP+SSE transport; the SDK still ships it for
+ # servers/clients that haven't migrated, and the tests still exercise it.
+ "ignore:.*HTTP\\+SSE transport is deprecated as of 2025-03-26.*:mcp.MCPDeprecationWarning",
]
</file context>
| print("=" * 60) | ||
| print(f"\nConnecting to: {server_url}") | ||
| print("(Start server with: cd examples/snippets && uv run server elicitation sse)") | ||
| print("(Start server with: cd examples/snippets && uv run server elicitation streamable-http)") |
There was a problem hiding this comment.
P3: When the initial Streamable HTTP connection is refused, the recovery message still tells users to start the server with sse. That starts the legacy transport instead of the /mcp endpoint this client now targets, so following the error guidance leaves the retry unable to connect. Updating the fallback (and the module usage text) to streamable-http would keep the example runnable.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At examples/snippets/clients/url_elicitation_client.py, line 289:
<comment>When the initial Streamable HTTP connection is refused, the recovery message still tells users to start the server with `sse`. That starts the legacy transport instead of the `/mcp` endpoint this client now targets, so following the error guidance leaves the retry unable to connect. Updating the fallback (and the module usage text) to `streamable-http` would keep the example runnable.</comment>
<file context>
@@ -280,16 +280,16 @@ async def run_command_loop(session: ClientSession) -> None:
print("=" * 60)
print(f"\nConnecting to: {server_url}")
- print("(Start server with: cd examples/snippets && uv run server elicitation sse)")
+ print("(Start server with: cd examples/snippets && uv run server elicitation streamable-http)")
try:
</file context>
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
src/mcp/server/mcpserver/server.py:396-399— The@deprecatedon therun(transport="sse")overload is typing-only (overload stubs never execute), so at runtime amcp.run(transport="sse")caller instead getsrun_sse_async's warning — "Use run_streamable_http_async instead" — naming an internal async method they never called, fired from inside theanyio.runlambda rather than their call site. Consider emitting the overload's message explicitly in thecase "sse"branch and suppressing the nestedrun_sse_asyncwarning, matching the pattern used elsewhere in this PR.Extended reasoning...
This PR's stated design (PR description and the suppression comments in
server.py) is that nested constructions suppress inner warnings "so the caller sees exactly one, naming the thing they called." That pattern is correctly implemented forsse_app()→SseServerTransport,run_sse_async()→sse_app(), and_establish_session()→sse_client()— but not for the documented entry pointmcp.run(transport="sse").The reason is that
typing_extensions.deprecatedapplied to an@overloadstub is a typing-only construct: overload stubs are rebound by the implementation and never execute at runtime, so the overload's message ('Use transport="streamable-http" instead') is only ever delivered by pyright/IDEs, never as a runtime warning. Verifiers confirmed this empirically with a minimal reproduction of the@deprecated+@overloadpattern.Step-by-step proof. A user writes
mcp.run(transport="sse"):- The
run()implementation atsrc/mcp/server/mcpserver/server.py:383executes; the@deprecatedoverload at lines 346–357 is never invoked, so no warning fires here. - The
case "sse"branch (line 398–399) callsanyio.run(lambda: self.run_sse_async(**kwargs))with only a# pyright: ignore[reportDeprecated]comment — unlike every other nested call site in this PR, it neither emits its own warning nor suppresses the inner one. - Inside the event loop,
run_sse_async's@deprecated(lines 1020–1023) fires the only runtimeMCPDeprecationWarning: "The HTTP+SSE transport is deprecated as of 2025-03-26 (SEP-2596). Use run_streamable_http_async instead." - The user is told to migrate to
run_streamable_http_async— an internal async method they never called — while the overload's typing-only message anddocs/migration.mdboth advertisetransport="streamable-http"as the migration path. The warning also originates inside theanyio.runlambda, so its stacklevel points at SDK internals rather than the user'srun()call.
Nothing existing prevents this: the
filterwarningsignore inpyproject.tomlkeeps the test suite green, and thecase "sse"branch carries# pragma: no cover, so no test observes which message fires on this path.Impact is limited: exactly one warning still fires (
run_sse_asyncsuppressessse_app's), the category is correct, and the guidance is directionally right (Streamable HTTP, SEP-2596). Only the replacement pointer and the warning's attribution are off relative to the PR's own one-warning-naming-the-caller design.Fix: in the
case "sse"branch ofrun(), emit the overload's message explicitly withwarnings.warn(..., category=MCPDeprecationWarning, stacklevel=2)and wrap therun_sse_asynccall inwarnings.catch_warnings()+simplefilter("ignore", MCPDeprecationWarning)— exactly the patternrun_sse_asyncandsse_appalready use for their own nested calls. - The
| @asynccontextmanager | ||
| @deprecated( | ||
| "The HTTP+SSE transport is deprecated as of 2025-03-26 (SEP-2596). Use streamable_http_client instead.", | ||
| category=MCPDeprecationWarning, | ||
| ) | ||
| async def sse_client( |
There was a problem hiding this comment.
🟡 The decorator order on sse_client (@asynccontextmanager outermost, @deprecated inner) causes the runtime MCPDeprecationWarning to be attributed to CPython's contextlib.py instead of the user's call site, and the default warning filter then dedupes all call sites process-wide to a single warning. Swapping the two decorators (@deprecated outermost) restores correct call-site attribution; sse_client is the only @asynccontextmanager function in this PR carrying @deprecated — the other deprecated entry points attribute correctly.
Extended reasoning...
What happens
Decorators apply bottom-up, so in src/mcp/client/sse.py the @deprecated wrapper wraps the raw async-generator function, and @asynccontextmanager wraps that. When a user calls sse_client(url), the call chain is: user frame → asynccontextmanager helper → _AsyncGeneratorContextManager.__init__ (self.gen = func(*args, **kwds)) → the deprecated wrapper. typing_extensions.deprecated emits warnings.warn(msg, stacklevel=2), and two frames up from the wrapper is _AsyncGeneratorContextManager.__init__ — so the warning is attributed to CPython's contextlib.py, not the caller's file and line.
Step-by-step proof (reproduced independently by three verifiers on Python 3.11):
- Define
sse_clientexactly as in this PR (@asynccontextmanagerover@deprecated(...)). - Call
sse_client("http://x/sse")from a user module and capture the warning withwarnings.catch_warnings(record=True). - Inspect the record:
filename == "/usr/lib/python3.11/contextlib.py",lineno == 105. The user sees.../contextlib.py:105: MCPDeprecationWarning: The HTTP+SSE transport is deprecated...with no pointer to their own code. - Swap the decorator order (
@deprecatedoutermost) and repeat: the record now points at the user's call line, and the returned object still works as an async context manager (async with sse_client(url) as streamsverified to behave identically).
Downstream consequences
- Python's default
"default"filter action dedupes per(message, category, module, lineno). Since everysse_clientcall in a process shares thecontextlib.py:105location, an application with several distinct call sites sees the warning once total, not once per call site — verified: two calls from different lines emitted one warning. module=-scoped filters (warnings.filterwarnings(..., module="myapp.legacy")) cannot target this warning, because its module resolves tocontextlib, not the caller's module.- This partially undercuts the PR's stated motivation ("nothing told users at the call site … a visible warning fires at runtime"): the warning fires, but it doesn't tell users where in their code the deprecated transport is used.
Why the PR's tests don't catch it
test_sse_client_warns_that_the_http_sse_transport_is_deprecated in tests/shared/test_sse.py uses pytest.warns and asserts only category and message text — never the attributed filename/lineno.
Why this is unique to sse_client
Every other entry point deprecated by this PR (SseServerParameters, SseServerTransport, sse_app, run_sse_async, the run(transport="sse") overload) is a plain class or method whose deprecated wrapper is invoked directly from the user's frame, so those warnings attribute correctly.
Fix
Swap the decorator order so @deprecated is outermost:
@deprecated(
"The HTTP+SSE transport is deprecated as of 2025-03-26 (SEP-2596). Use streamable_http_client instead.",
category=MCPDeprecationWarning,
)
@asynccontextmanager
async def sse_client(...):The warning still fires eagerly at sse_client(url) construction (before any connection), the returned context manager is unchanged, and both pyright's reportDeprecated flagging and the existing pytest.warns tests are unaffected. (Alternatively, stacklevel=3 would work, but typing_extensions.deprecated doesn't expose that knob — the swap is the clean one-line fix.)
Severity
Nit: the warning does fire visibly with the correct category and its message names the transport, so nothing breaks — only the file/line attribution, per-call-site dedup, and module-scoped filtering are degraded relative to the PR's call-site-visibility goal. One-line fix, not worth blocking merge.
| server_url = "http://localhost:8000/mcp" | ||
|
|
||
| print("=" * 60) | ||
| print("URL Elicitation Client Example") | ||
| print("=" * 60) | ||
| print(f"\nConnecting to: {server_url}") | ||
| print("(Start server with: cd examples/snippets && uv run server elicitation sse)") | ||
| print("(Start server with: cd examples/snippets && uv run server elicitation streamable-http)") | ||
|
|
||
| try: | ||
| async with sse_client(server_url) as (read, write): | ||
| async with streamable_http_client(server_url) as (read, write): | ||
| async with ClientSession( | ||
| read, | ||
| write, |
There was a problem hiding this comment.
🟡 The migration of this client to Streamable HTTP left two stale instructions that still say to start the server in SSE mode: the module docstring (uv run server elicitation sse, ~line 20) and the ConnectionRefusedError handler (line 305). Following either starts the server on the legacy /sse endpoint while this client now connects to /mcp, so the connection fails — and the failure message then repeats the wrong command. Both should say streamable-http to match the updated hint in main().
Extended reasoning...
What the bug is. This PR migrates examples/snippets/clients/url_elicitation_client.py from sse_client to streamable_http_client: the import changes, server_url becomes http://localhost:8000/mcp, and the startup hint printed in main() (line 289) is updated to uv run server elicitation streamable-http. But two other references to the old command in the same file were missed:
- The module docstring (~line 20):
uv run server elicitation sse - The
ConnectionRefusedErrorhandler (line 305):print(" cd examples/snippets && uv run server elicitation sse")
The code path that triggers it. The snippets server runner (examples/snippets/servers/__init__.py) dispatches its transport argument to mcp.run(transport), so uv run server elicitation sse really does start the server on the legacy HTTP+SSE transport, serving /sse and /messages/ — there is no /mcp endpoint in that mode.
Step-by-step proof.
- A user reads the module docstring and runs
cd examples/snippets && uv run server elicitation sse. The server starts, listening for legacy SSE clients athttp://localhost:8000/sse. - They run the client. It calls
streamable_http_client("http://localhost:8000/mcp")(lines 283, 292) and POSTs to/mcp. - The SSE-mode Starlette app has no
/mcproute, so the request fails (404 → connection error surfaced to the user). - The client's error path prints
Make sure the elicitation server is running: cd examples/snippets && uv run server elicitation sse— the exact command the user already ran. The recovery instruction loops them back into the failure.
Beyond the connection failure, following the stale instruction also puts the user on the transport this very PR is deprecating: the server run emits MCPDeprecationWarning, the opposite of what the snippet is meant to demonstrate post-migration.
Why nothing catches it. These are string literals in comments and print statements — no type checker, test, or the new deprecation warnings can flag them. The example only fails when run interactively against a real server, which CI doesn't do.
Impact and fix. Impact is confined to this example: the primary hint in main() (line 289) is correct, so a user who starts from the printed banner is fine; only users who follow the docstring or the error-path message get misdirected. The fix is a single root cause with two one-line string changes — replace sse with streamable-http in the docstring and in the ConnectionRefusedError handler, matching line 289 exactly (the runner accepts streamable-http as a transport argument, verified against examples/snippets/servers/__init__.py).
There was a problem hiding this comment.
Beyond the inline findings, I also checked cubic's P2 claim that ClientSessionGroup._establish_session still emits two deprecation warnings (one from SseServerParameters, one from sse_client) — it does not. With @asynccontextmanager outermost, the @deprecated wrapper runs at context-manager construction, i.e. inside the catch_warnings block; entering the CM later via enter_async_context emits nothing (verified empirically). The caller sees exactly one warning, from building SseServerParameters.
Extended reasoning...
Bugs from this run are already attached as inline comments, so no full review body is warranted. This note records one additional concern that was examined and ruled out: cubic-dev-ai's P2 on src/mcp/client/session_group.py claims the suppression must extend through enter_async_context, but the deprecation warning for sse_client fires when the context manager object is constructed (the deprecated wrapper is invoked from _AsyncGeneratorContextManager.__init__), which happens inside the existing catch_warnings block — entering the context manager afterwards re-runs nothing that warns. Reproduced with a minimal replica of the exact decorator stack and call pattern: zero warnings escape the suppression block.
| @overload | ||
| @deprecated( | ||
| 'transport="sse" is deprecated: the HTTP+SSE transport is deprecated as of 2025-03-26 ' | ||
| '(SEP-2596). Use transport="streamable-http" instead.', | ||
| category=MCPDeprecationWarning, | ||
| ) | ||
| def run( | ||
| self, | ||
| transport: Literal["sse"], |
There was a problem hiding this comment.
🟡 🟡 The @deprecated decorator on the run(transport="sse") overload has no runtime effect (PEP 702: a decorated @overload stub only informs static checkers), so at runtime a mcp.run(transport="sse") caller instead sees the nested run_sse_async warning — "Use run_streamable_http_async instead" — naming an async API they never called, when the direct migration is transport="streamable-http" in the same run() call. Fix by warnings.warn()-ing the overload's transport-oriented message in the case "sse" branch and suppressing the nested run_sse_async warning with catch_warnings, matching the pattern run_sse_async already uses for sse_app.
Extended reasoning...
What the bug is. This PR stacks @overload over @deprecated on the run(transport: Literal["sse"]) overload (src/mcp/server/mcpserver/server.py:361-369). Per PEP 702 / typing_extensions, @deprecated applied to an @overload stub only informs static checkers: typing.overload registers the stub and returns a dummy that is never called, and the stub function object is discarded when the implementation def run(...) executes. So the overload's carefully worded message — 'transport="sse" is deprecated … Use transport="streamable-http" instead.' — is never emitted at runtime. Three independent verifiers reproduced this empirically with a minimal replica of the exact decorator structure: zero warnings fire from the overload decorator.
The code path that triggers it. mcp.run(transport="sse") reaches the implementation's case "sse" branch, which dispatches anyio.run(lambda: self.run_sse_async(**kwargs)). run_sse_async is genuinely decorated, so its warning fires — but that is the wrong message for this caller.
Step-by-step proof.
- A user calls
mcp.run(transport="sse")— a synchronous, top-level entry point. - The overload's
@deprecatedemits nothing (stub discarded; the implementation has no__deprecated__attribute). - The
case "sse"branch invokesrun_sse_asyncvia a lambda; its@deprecatedwrapper fires:MCPDeprecationWarning: The HTTP+SSE transport is deprecated as of 2025-03-26 (SEP-2596). Use run_streamable_http_async instead. - The user is told to migrate to an async method they never touched, when the actual one-token fix is
transport="streamable-http"in the call they already have. The warning is also attributed to the lambda insideserver.py, not the user's call site.
Why existing code doesn't prevent it. The PR's stated design is that nested constructions suppress inner warnings "so the caller sees exactly one, naming the thing they called" — and the authors did add catch_warnings suppression for run_sse_async → sse_app and sse_app → SseServerTransport. The run() → run_sse_async hop is the one link in the chain with no explicit runtime warning above it, presumably because the overload decorator was assumed to fire at runtime. No test catches it: the case "sse" branch is # pragma: no cover, and the three new warning tests cover sse_client, SseServerTransport, and sse_app() only.
Impact. Limited: exactly one MCPDeprecationWarning still fires with the correct category, the correct SEP-2596 reference, and a workable (if indirect) migration path — and pyright still flags the call site correctly via the overload. Nothing breaks; the message is merely misdirected for the highest-level, most-documented entry point (run() is what mcp run --transport sse and every quickstart-style server use).
How to fix. In the implementation's case "sse" branch, explicitly warnings.warn() the overload's transport-oriented message (as MCPDeprecationWarning, with a stacklevel pointing at the caller), then suppress the nested run_sse_async warning with warnings.catch_warnings() + simplefilter("ignore", MCPDeprecationWarning) — the exact pattern run_sse_async already applies to its own nested sse_app() call. Keeping the @deprecated on the overload is still right for static checkers; it just needs a runtime counterpart.
Marks every public entry point of the legacy HTTP+SSE transport as deprecated. Streamable HTTP superseded it in the 2025-03-26 spec revision, and it now sits in the spec's deprecated-features registry under SEP-2596. Streamable HTTP (and its request-scoped SSE streaming) is untouched.
Motivation and Context
The SDK still ships HTTP+SSE for clients and servers that haven't migrated, but nothing told users at the call site or in the type checker that they were building on a deprecated transport. This uses the same mechanism as the SEP-2577 deprecations:
typing_extensions.deprecated+MCPDeprecationWarning, so pyright/IDEs flag the call and a visible warning fires at runtime.Deprecated:
mcp.client.sse.sse_client()mcp.client.session_group.SseServerParametersmcp.server.sse.SseServerTransportMCPServer.sse_app(),MCPServer.run_sse_async(), and therun(transport="sse")overloadNested constructions (
sse_app()→SseServerTransport,run_sse_async()→sse_app(),connect_to_server()→sse_client()) suppress the inner warning so the caller sees exactly one, naming the thing they called.Adjacent housekeeping:
mcp run --transporthelp now advertisesstdio or streamable-http,python -m mcp.client <url>connects with Streamable HTTP (it used SSE for anyhttp(s)URL, unlikeClient(str)), and theurl_elicitation_clientsnippet moves to Streamable HTTP.How Has This Been Tested?
Three new tests assert the warning fires (client transport, server transport, and that
sse_app()warns exactly once). A scopedfilterwarningsignore keeps the existing SSE suites running undererror. Full suite green locally.Breaking Changes
None. Everything keeps working; it warns. Documented in
docs/migration.md(### HTTP+SSE transport deprecated (SEP-2596)) anddocs/deprecated.md.Types of changes
Checklist
Additional context
Message text lines up with the spec registry row (deprecated in
2025-03-26, migration path Streamable HTTP): "The HTTP+SSE transport is deprecated as of 2025-03-26 (SEP-2596). Use <replacement> instead."Other SDKs for reference: TS marks
SSEClientTransport/SSEServerTransport@deprecated; C# gatesEnableLegacySsebehind an[Obsolete]diagnostic; Java 2.0 deprecates its SSE transports.AI Disclaimer