Skip to content

Commit eddfa29

Browse files
committed
Deprecate ServerSession.send_progress_notification
`ServerSession.send_progress_notification` takes an explicit progress token decoupled from the request it belongs to, so it can keep emitting progress for a request that has already completed -- which the spec forbids ("Progress notifications MUST stop after completion"). The request-scoped `report_progress` (and `Context.report_progress`) is the supported path: it reports against the inbound request's own token, no-ops when the caller did not ask for progress, and stops when the request completes. The deprecated method keeps working and emits `MCPDeprecationWarning`. The warning message deliberately departs from the "<X> is deprecated as of <version>" pattern used by the spec-driven deprecations: this one is an SDK API decision, not a spec retirement (2026-07-28 does not retire server-to-client progress). For "stops when the request completes" to hold on every dispatcher, `_DirectDispatchContext` now closes with its request the way `_JSONRPCDispatchContext` already did: `close()` runs in the dispatch handler's `finally`, after which `progress`/`notify` deliver nothing, `can_send_request` is False, and `send_raw_request` raises `NoBackChannelError` -- the closed state the `DispatchContext` protocol documents. Two pre-existing tests that asserted `can_send_request` on a context captured after its handler returned now sample it in-handler, and the closed-state contract tests are parametrized over both dispatchers. The interaction test that covered both the server and client side of late progress is split in two. The server-side property is proved positively on the wire through `report_progress`; it no longer relies on a session-bound standalone stream, so it also runs on the stateless streamable-http arm. The client-side late-drop test keeps using the deprecated explicit-token method -- the only API that can still produce a late notification -- under `pytest.warns`, and its arms are unchanged. The migration guide stops recommending the deprecated method anywhere and documents the replacement.
1 parent 28f500c commit eddfa29

10 files changed

Lines changed: 273 additions & 48 deletions

File tree

docs/advanced/deprecated.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ The table below names each deprecated feature, why it is going away, and the rep
1212
| **Server-initiated sampling**: `ctx.session.create_message()`, the `sampling_callback=` you pass to `Client(...)` | SEP-2577 retires the capability. | Return `InputRequiredResult` and let the client retry the call (see **Multi-round-trip requests**). |
1313
| **Protocol logging**: `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | SEP-2577 retires the capability. Nothing in-protocol replaces it. | Ordinary `import logging` to stderr (see **Logging**). |
1414
| **`ping`**: `client.send_ping()` | **Removed** from the protocol, not merely deprecated. There is no `ping` method in 2026-07-28. | Nothing. It only works against a `mode="legacy"` connection. |
15-
| **Client->server progress**: `client.send_progress_notification()` | 2026-07-28 makes progress server->client only. | Nothing to send. Your *server* reports progress with `ctx.report_progress()` (see **Progress**). |
15+
| **Client->server progress**: `client.send_progress_notification()` | 2026-07-28 makes progress server->client only. | Nothing to send. Your *server* reports progress with `ctx.report_progress()` (see **Progress**). The SDK separately deprecates the server-side explicit-token `ctx.session.send_progress_notification()` in favour of `report_progress` (same warning category, same replacement). |
1616

1717
Three things fall out of that table:
1818

docs/migration.md

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -905,7 +905,7 @@ async def handle_tool(name: str, arguments: dict) -> list[TextContent]:
905905
# After (v2)
906906
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
907907
if ctx.meta and "progress_token" in ctx.meta:
908-
await ctx.session.send_progress_notification(ctx.meta["progress_token"], 0.5, 100)
908+
await ctx.session.report_progress(0.5, 100)
909909
...
910910

911911
server = Server("my-server", on_call_tool=handle_call_tool)
@@ -967,7 +967,7 @@ async def my_tool(ctx: Context[MyLifespanState]) -> str: ...
967967

968968
### `ProgressContext` and `progress()` context manager removed
969969

970-
The `mcp.shared.progress` module (`ProgressContext`, `Progress`, and the `progress()` context manager) has been removed. This module had no real-world adoption — all users send progress notifications via `Context.report_progress()` or `session.send_progress_notification()` directly.
970+
The `mcp.shared.progress` module (`ProgressContext`, `Progress`, and the `progress()` context manager) has been removed. This module had no real-world adoption — all users send progress notifications via `Context.report_progress()` directly.
971971

972972
**Before (v1):**
973973

@@ -987,21 +987,11 @@ async def my_tool(x: int, ctx: Context) -> str:
987987
return "done"
988988
```
989989

990-
**After — use `session.send_progress_notification()` (low-level):**
991-
992-
```python
993-
await session.send_progress_notification(
994-
progress_token=progress_token,
995-
progress=25,
996-
total=100,
997-
)
998-
```
999-
1000990
### Handler progress reporting: prefer `ctx.report_progress()` over manual `progress_token`
1001991

1002992
Reading `ctx.meta["progress_token"]` and calling `session.send_progress_notification(token, ...)` is specific to the JSON-RPC transport path. On the in-process modern path (`DirectDispatcher` / `Client(server)`), there is no wire token in `_meta`, so handlers that gate progress on the token's presence go silent.
1003993

1004-
`ctx.report_progress(progress, total, message)` works on every dispatcher: it sends a progress notification when a token is present and routes the update through the dispatcher's progress channel otherwise, no-opping only when the caller did not request progress at all. `session.send_progress_notification(progress_token, ...)` is unchanged and still works on JSON-RPC transports for code that already holds a token.
994+
`ctx.report_progress(progress, total, message)` works on every dispatcher: it sends a progress notification when a token is present and routes the update through the dispatcher's progress channel otherwise, no-opping only when the caller did not request progress at all. `ServerSession.send_progress_notification(progress_token, ...)` is now deprecated; see **Progress API deprecations** below.
1005995

1006996
### `create_connected_server_and_client_session` removed
1007997

@@ -1498,11 +1488,23 @@ warnings.filterwarnings("ignore", category=MCPDeprecationWarning)
14981488

14991489
No migration is required during the deprecation window. New code should avoid building on these features, since they may be removed in a future spec version.
15001490

1501-
### Client-to-server progress deprecated (2026-07-28)
1491+
### Progress API deprecations (2026-07-28)
15021492

15031493
The 2026-07-28 spec restricts `notifications/progress` to the server-to-client direction only — `ProgressNotification` is no longer in `ClientNotification`. `Client.send_progress_notification()` and `ClientSession.send_progress_notification()` now carry `typing_extensions.deprecated` and emit `mcp.MCPDeprecationWarning` at runtime. They continue to work against servers negotiating 2025-11-25 or earlier.
15041494

1505-
On the server side, prefer the new dispatcher-agnostic `ServerSession.report_progress(progress, total, message)` (and `Context.report_progress()` on `MCPServer`) over the raw `ServerSession.send_progress_notification(progress_token, …)`. `report_progress` encapsulates the "no-op when the caller did not request progress" rule and works on every dispatcher; the raw token-taking form remains for handlers that read `_meta.progressToken` directly.
1495+
On the server side, `ServerSession.send_progress_notification(progress_token, ...)` is also deprecated. It takes an explicit progress token decoupled from any request's lifetime, so it can emit progress for a request that has already completed -- which the spec forbids ("Progress notifications MUST stop after completion"). Use `Context.report_progress(progress, total, message)` (or `ServerSession.report_progress(...)` from a low-level handler): it reports against the inbound request's own progress token, works on every dispatcher, no-ops when the caller did not request progress, and is closed with the request, so a late call after the handler has returned sends nothing.
1496+
1497+
```python
1498+
# Before
1499+
token = ctx.meta.get("progress_token")
1500+
if token is not None:
1501+
await ctx.session.send_progress_notification(token, 0.5, total=1.0)
1502+
1503+
# After
1504+
await ctx.report_progress(0.5, total=1.0)
1505+
```
1506+
1507+
The deprecated method still works during the advisory window and emits `mcp.MCPDeprecationWarning`.
15061508

15071509
## Bug Fixes
15081510

src/mcp/server/session.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,11 @@ async def report_progress(self, progress: float, total: float | None = None, mes
363363
"""
364364
await self._request_outbound.progress(progress, total, message)
365365

366+
@deprecated(
367+
"send_progress_notification is deprecated; use report_progress, which is scoped to "
368+
"the inbound request's progress token and stops when that request completes.",
369+
category=MCPDeprecationWarning,
370+
)
366371
async def send_progress_notification(
367372
self,
368373
progress_token: str | int,

src/mcp/shared/direct_dispatcher.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,16 @@ class _DirectDispatchContext:
6161
"""Always `None`: in-memory dispatch attaches no transport metadata."""
6262
_on_progress: ProgressFnT | None = None
6363
cancel_requested: anyio.Event = field(default_factory=anyio.Event)
64+
_closed: bool = False
6465

6566
@property
6667
def can_send_request(self) -> bool:
67-
return self.transport.can_send_request
68+
return self.transport.can_send_request and not self._closed
6869

6970
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
71+
if self._closed:
72+
logger.debug("dropped %s: dispatch context closed", method)
73+
return
7074
await self._back_notify(method, params)
7175

7276
async def send_raw_request(
@@ -80,8 +84,14 @@ async def send_raw_request(
8084
return await self._back_request(method, params, opts)
8185

8286
async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
83-
if self._on_progress is not None:
84-
await self._on_progress(progress, total, message)
87+
# Gated here, not via notify(): in-process progress never routes through
88+
# notify() - it awaits the caller's callback inline (a pinned behaviour).
89+
if self._closed or self._on_progress is None:
90+
return
91+
await self._on_progress(progress, total, message)
92+
93+
def close(self) -> None:
94+
self._closed = True
8595

8696

8797
class DirectDispatcher:
@@ -241,6 +251,12 @@ async def _dispatch_request(
241251
if unexpected:
242252
logger.exception("request handler raised")
243253
raise MCPError(code=error.code, message=error.message, data=error.data) from None
254+
finally:
255+
# Close the back-channel: after the handler returns, a captured
256+
# context's `progress`/`notify` deliver nothing and its
257+
# `send_raw_request` raises `NoBackChannelError`, matching
258+
# JSONRPCDispatcher's handler-finally.
259+
dctx.close()
244260
except TimeoutError:
245261
raise MCPError(
246262
code=REQUEST_TIMEOUT,

tests/interaction/_requirements.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -655,8 +655,11 @@ def __post_init__(self) -> None:
655655
),
656656
divergence=Divergence(
657657
note=(
658-
"The spec MUST is not enforced: progress values are not validated on either side, so a "
659-
"handler that emits non-increasing values has them forwarded to the callback unchanged."
658+
"Intentional, not a gap to close: no MCP SDK (typescript, go, csharp) validates "
659+
"sender-side progress monotonicity, and this one does not either. The spec MUST "
660+
"binds the handler author, not the transport; non-increasing values are forwarded "
661+
"to the callback unchanged so the receiving application sees what the sender sent. "
662+
"docs/tutorial/progress.md states the author's obligation."
660663
),
661664
),
662665
),
@@ -665,13 +668,28 @@ def __post_init__(self) -> None:
665668
behavior="Progress notifications for a token stop once the associated request completes.",
666669
divergence=Divergence(
667670
note=(
668-
"send_progress_notification does not check whether the token's request has already "
669-
"completed; the late notification is sent and reaches the client."
671+
"Holds on the supported path: report_progress is scoped to the inbound request's "
672+
"dispatch context, which closes when the request completes, so a late report is a "
673+
"no-op (proven by test_report_progress_after_the_request_completes_sends_nothing). "
674+
"The deprecated explicit-token ServerSession.send_progress_notification has no "
675+
"such gate and still delivers post-completion progress to the client (proven by "
676+
"the test that pins the client-side late-drop). The gap closes when that method "
677+
"is removed."
670678
),
671679
),
672680
arm_exclusions=(
673-
ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),
674-
ArmExclusion(reason="requires-session", spec_version="2026-07-28"),
681+
ArmExclusion(
682+
reason="requires-session",
683+
spec_version="2026-07-28",
684+
note=(
685+
"The wire proof observes notifications/progress via the message handler; "
686+
"neither 2026-07-28 cell can produce one. The in-memory DirectDispatcher "
687+
"delivers progress as an in-process callback and never constructs the "
688+
"notification; the modern streamable-http dispatch context no-ops notify(). "
689+
"The DirectDispatcher half of the property is covered by "
690+
"tests/shared/test_dispatcher.py instead."
691+
),
692+
),
675693
),
676694
),
677695
"protocol:progress:late-dropped-by-client": Requirement(

tests/interaction/lowlevel/test_progress.py

Lines changed: 87 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
Server-to-client progress emitted during a request follows the same ordering guarantee as
44
logging notifications (see test_logging.py) -- on the in-memory transport unconditionally, and
55
over streamable HTTP only when sent with ``related_request_id`` so the notification rides the
6-
originating request's POST stream rather than the standalone GET stream. These tests pass
7-
``related_request_id`` so no synchronisation is needed. The client-to-server direction is a
8-
standalone notification with no response to await, so that test waits on an event set by the
9-
server's handler.
6+
originating request's POST stream rather than the standalone GET stream. These tests report
7+
through the request-scoped `report_progress`, which routes onto that stream, so no
8+
synchronisation is needed. The client-to-server direction is a standalone notification with no
9+
response to await, so that test waits on an event set by the server's handler.
1010
"""
1111

1212
import anyio
@@ -15,6 +15,7 @@
1515
from inline_snapshot import snapshot
1616
from mcp_types import CallToolResult, ProgressNotification, ProgressNotificationParams, ProgressToken, TextContent
1717

18+
from mcp import MCPDeprecationWarning
1819
from mcp.server import Server, ServerRequestContext
1920
from mcp.server.session import ServerSession
2021
from mcp.shared.session import ProgressFnT
@@ -197,15 +198,86 @@ async def call(label: str, collect: ProgressFnT) -> None:
197198

198199

199200
@requirement("protocol:progress:stops-after-completion")
201+
async def test_report_progress_after_the_request_completes_sends_nothing(connect: Connect) -> None:
202+
"""Progress reported through `report_progress` after the request has completed never reaches
203+
the client.
204+
205+
The handler captures its `ServerSession`; once `call_tool` has returned, the request's
206+
dispatch context is closed, so the late `report_progress` is dropped before any I/O. The
207+
message handler is teed every inbound progress notification (matched-to-callback or not), so
208+
`seen_on_wire == [0.5]` is a positive full-equality proof: the in-handler report arrived and
209+
nothing else ever did. The `list_tools` round-trip after the late report flushes anything a
210+
regression would have put in flight, so the negative is not racy.
211+
212+
The arms here are all `JSONRPCDispatcher` (the two 2026-07-28 cells are excluded: neither can
213+
put a `notifications/progress` message on the wire); the `DirectDispatcher` half of the same
214+
close is proven by
215+
`tests/shared/test_dispatcher.py::test_ctx_progress_and_notify_after_the_inbound_request_returns_are_dropped`,
216+
which is parametrized over both dispatchers.
217+
218+
Steps:
219+
1. The tool reports 0.5 through `report_progress` and captures `ctx.session`.
220+
2. After `call_tool` returns, wait until 0.5 has been observed on the wire.
221+
3. Call `report_progress(1.0)` on the captured session -- a no-op on the closed context.
222+
4. A second `list_tools` round-trip flushes the single ordered stream.
223+
5. Tear the connection down; assert the wire saw exactly [0.5].
224+
"""
225+
captured: list[ServerSession] = []
226+
227+
async def list_tools(
228+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
229+
) -> types.ListToolsResult:
230+
return types.ListToolsResult(tools=[types.Tool(name="report", input_schema={"type": "object"})])
231+
232+
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
233+
assert params.name == "report"
234+
captured.append(ctx.session)
235+
await ctx.session.report_progress(0.5)
236+
return CallToolResult(content=[TextContent(text="done")])
237+
238+
server = Server("reporter", on_list_tools=list_tools, on_call_tool=call_tool)
239+
240+
received: list[float] = []
241+
seen_on_wire: list[float] = []
242+
first_seen = anyio.Event()
243+
244+
async def collect(progress: float, total: float | None, message: str | None) -> None:
245+
received.append(progress)
246+
247+
async def message_handler(message: IncomingMessage) -> None:
248+
assert isinstance(message, ProgressNotification)
249+
seen_on_wire.append(message.params.progress)
250+
first_seen.set()
251+
252+
async with connect(server, message_handler=message_handler) as client:
253+
with anyio.fail_after(5):
254+
await client.call_tool("report", {}, progress_callback=collect)
255+
await first_seen.wait()
256+
await captured[0].report_progress(1.0)
257+
await client.list_tools()
258+
259+
assert received == [0.5]
260+
assert seen_on_wire == [0.5]
261+
262+
200263
@requirement("protocol:progress:late-dropped-by-client")
201-
async def test_progress_sent_after_the_response_is_not_delivered_to_the_callback(connect: Connect) -> None:
202-
"""A progress notification sent after the response is emitted, and the client drops it from the callback.
203-
204-
This single body proves both halves: the server's `send_progress_notification` happily sends for
205-
a token whose request has already completed (the spec MUST that progress stops is not enforced;
206-
see the divergence on `stops-after-completion`), and the client, having removed the callback when
207-
the call returned, does not deliver the late notification to it. The message handler observes the
208-
late notification arriving so the test knows when to assert without polling.
264+
@requirement("protocol:progress:stops-after-completion")
265+
async def test_a_progress_notification_arriving_after_the_response_is_dropped_from_the_callback(
266+
connect: Connect,
267+
) -> None:
268+
"""A progress notification that arrives after its request has completed is not delivered to
269+
the original progress callback.
270+
271+
SDK-defined: the client removes the per-call progress callback when `call_tool` returns.
272+
Producing a genuinely late notification requires the deprecated explicit-token
273+
`ServerSession.send_progress_notification` -- the only API not tied to the request's
274+
dispatch context -- used deliberately here under `pytest.warns`. The message handler
275+
observes the late notification arriving so the test knows when to assert without polling.
276+
277+
The arrival itself pins the server-side half: the deprecated explicit-token method has no
278+
completion gate, so the late notification still reaches the wire. That is the known
279+
stops-after-completion divergence; the spec-conforming `report_progress` path is proven
280+
separately by `test_report_progress_after_the_request_completes_sends_nothing`.
209281
"""
210282
captured: list[tuple[ServerSession, ProgressToken]] = []
211283

@@ -220,7 +292,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
220292
token = ctx.meta.get("progress_token")
221293
assert token is not None
222294
captured.append((ctx.session, token))
223-
await ctx.session.send_progress_notification(token, 0.5, related_request_id=str(ctx.request_id))
295+
await ctx.session.report_progress(0.5)
224296
return CallToolResult(content=[TextContent(text="done")])
225297

226298
server = Server("reporter", on_list_tools=list_tools, on_call_tool=call_tool)
@@ -241,7 +313,8 @@ async def message_handler(message: IncomingMessage) -> None:
241313
assert received == [0.5]
242314

243315
server_session, token = captured[0]
244-
await server_session.send_progress_notification(token, 1.0)
316+
with pytest.warns(MCPDeprecationWarning, match=r"^send_progress_notification is deprecated"):
317+
await server_session.send_progress_notification(token, 1.0) # pyright: ignore[reportDeprecated]
245318
await late_progress_arrived.wait()
246319

247320
assert received == [0.5]

0 commit comments

Comments
 (0)