Skip to content

Commit 88a69d7

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 48bc3f4 commit 88a69d7

10 files changed

Lines changed: 260 additions & 52 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
@@ -831,7 +831,7 @@ async def handle_tool(name: str, arguments: dict) -> list[TextContent]:
831831
# After (v2)
832832
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
833833
if ctx.meta and "progress_token" in ctx.meta:
834-
await ctx.session.send_progress_notification(ctx.meta["progress_token"], 0.5, 100)
834+
await ctx.session.report_progress(0.5, 100)
835835
...
836836

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

894894
### `ProgressContext` and `progress()` context manager removed
895895

896-
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.
896+
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.
897897

898898
**Before (v1):**
899899

@@ -913,21 +913,11 @@ async def my_tool(x: int, ctx: Context) -> str:
913913
return "done"
914914
```
915915

916-
**After — use `session.send_progress_notification()` (low-level):**
917-
918-
```python
919-
await session.send_progress_notification(
920-
progress_token=progress_token,
921-
progress=25,
922-
total=100,
923-
)
924-
```
925-
926916
### Handler progress reporting: prefer `ctx.report_progress()` over manual `progress_token`
927917

928918
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.
929919

930-
`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.
920+
`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.
931921

932922
### `create_connected_server_and_client_session` removed
933923

@@ -1422,11 +1412,23 @@ warnings.filterwarnings("ignore", category=MCPDeprecationWarning)
14221412

14231413
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.
14241414

1425-
### Client-to-server progress deprecated (2026-07-28)
1415+
### Progress API deprecations (2026-07-28)
14261416

14271417
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.
14281418

1429-
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.
1419+
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.
1420+
1421+
```python
1422+
# Before
1423+
token = ctx.meta.get("progress_token")
1424+
if token is not None:
1425+
await ctx.session.send_progress_notification(token, 0.5, total=1.0)
1426+
1427+
# After
1428+
await ctx.report_progress(0.5, total=1.0)
1429+
```
1430+
1431+
The deprecated method still works during the advisory window and emits `mcp.MCPDeprecationWarning`.
14301432

14311433
## Bug Fixes
14321434

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: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -667,8 +667,11 @@ def __post_init__(self) -> None:
667667
),
668668
divergence=Divergence(
669669
note=(
670-
"The spec MUST is not enforced: progress values are not validated on either side, so a "
671-
"handler that emits non-increasing values has them forwarded to the callback unchanged."
670+
"Intentional, not a gap to close: no MCP SDK (typescript, go, csharp) validates "
671+
"sender-side progress monotonicity, and this one does not either. The spec MUST "
672+
"binds the handler author, not the transport; non-increasing values are forwarded "
673+
"to the callback unchanged so the receiving application sees what the sender sent. "
674+
"docs/tutorial/progress.md states the author's obligation."
672675
),
673676
),
674677
known_failures=(
@@ -678,15 +681,19 @@ def __post_init__(self) -> None:
678681
"protocol:progress:stops-after-completion": Requirement(
679682
source=f"{SPEC_BASE_URL}/basic/utilities/progress#behavior-requirements",
680683
behavior="Progress notifications for a token stop once the associated request completes.",
681-
divergence=Divergence(
682-
note=(
683-
"send_progress_notification does not check whether the token's request has already "
684-
"completed; the late notification is sent and reaches the client."
685-
),
686-
),
687684
arm_exclusions=(
688-
ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),
689-
ArmExclusion(reason="requires-session", spec_version="2026-07-28"),
685+
ArmExclusion(
686+
reason="requires-session",
687+
spec_version="2026-07-28",
688+
note=(
689+
"The wire proof observes notifications/progress via the message handler; "
690+
"neither 2026-07-28 cell can produce one. The in-memory DirectDispatcher "
691+
"delivers progress as an in-process callback and never constructs the "
692+
"notification; the modern streamable-http dispatch context no-ops notify(). "
693+
"The DirectDispatcher half of the property is covered by "
694+
"tests/shared/test_dispatcher.py instead."
695+
),
696+
),
690697
),
691698
),
692699
"protocol:progress:late-dropped-by-client": Requirement(

tests/interaction/lowlevel/test_progress.py

Lines changed: 81 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,80 @@ 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+
async def test_a_progress_notification_arriving_after_the_response_is_dropped_from_the_callback(
265+
connect: Connect,
266+
) -> None:
267+
"""A progress notification that arrives after its request has completed is not delivered to
268+
the original progress callback.
269+
270+
SDK-defined: the client removes the per-call progress callback when `call_tool` returns.
271+
Producing a genuinely late notification requires the deprecated explicit-token
272+
`ServerSession.send_progress_notification` -- the only API not tied to the request's
273+
dispatch context -- used deliberately here under `pytest.warns`. The message handler
274+
observes the late notification arriving so the test knows when to assert without polling.
209275
"""
210276
captured: list[tuple[ServerSession, ProgressToken]] = []
211277

@@ -220,7 +286,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
220286
token = ctx.meta.get("progress_token")
221287
assert token is not None
222288
captured.append((ctx.session, token))
223-
await ctx.session.send_progress_notification(token, 0.5, related_request_id=str(ctx.request_id))
289+
await ctx.session.report_progress(0.5)
224290
return CallToolResult(content=[TextContent(text="done")])
225291

226292
server = Server("reporter", on_list_tools=list_tools, on_call_tool=call_tool)
@@ -241,7 +307,8 @@ async def message_handler(message: IncomingMessage) -> None:
241307
assert received == [0.5]
242308

243309
server_session, token = captured[0]
244-
await server_session.send_progress_notification(token, 1.0)
310+
with pytest.warns(MCPDeprecationWarning, match=r"^send_progress_notification is deprecated"):
311+
await server_session.send_progress_notification(token, 1.0) # pyright: ignore[reportDeprecated]
245312
await late_progress_arrived.wait()
246313

247314
assert received == [0.5]

tests/server/test_server_context.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,14 @@ class _Lifespan:
3232
async def test_context_exposes_lifespan_and_connection_and_forwards_base_context():
3333
captured: list[Context[_Lifespan]] = []
3434
conn_holder: list[Connection] = []
35+
open_while_handling: list[bool] = []
3536

3637
async def server_on_request(dctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
3738
ctx: Context[_Lifespan] = Context(dctx, lifespan=_Lifespan("app"), connection=conn_holder[0])
3839
captured.append(ctx)
40+
# `can_send_request` is sampled in-handler: the dispatch context closes when the
41+
# request returns, after which it is False on every dispatcher.
42+
open_while_handling.append(ctx.can_send_request)
3943
return {}
4044

4145
async with running_pair(direct_pair, server_on_request=server_on_request) as (client, server, *_):
@@ -46,7 +50,7 @@ async def server_on_request(dctx: DCtx, method: str, params: Mapping[str, Any] |
4650
assert ctx.lifespan.name == "app"
4751
assert ctx.connection is conn_holder[0]
4852
assert ctx.transport.kind == "direct"
49-
assert ctx.can_send_request is True
53+
assert open_while_handling == [True]
5054
assert ctx.session_id == "sess-1"
5155
assert ctx.headers is None
5256

0 commit comments

Comments
 (0)