eddfa29d9d
`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.
142 lines
5.7 KiB
Python
142 lines
5.7 KiB
Python
"""Tests for `BaseContext`.
|
|
|
|
`BaseContext` is composition over a `DispatchContext` - it forwards
|
|
`transport`/`cancel_requested`/`send_raw_request`/`notify`/`progress`
|
|
and adds `meta`. It must satisfy `Outbound` so `ClientPeer` can wrap it.
|
|
"""
|
|
|
|
from collections.abc import Mapping
|
|
from typing import Any
|
|
|
|
import anyio
|
|
import pytest
|
|
|
|
from mcp.shared.context import BaseContext
|
|
from mcp.shared.dispatcher import DispatchContext
|
|
from mcp.shared.peer import ClientPeer
|
|
from mcp.shared.transport_context import TransportContext
|
|
|
|
from .conftest import PairFactory, direct_pair
|
|
from .test_dispatcher import Recorder, echo_handlers, running_pair
|
|
|
|
DCtx = DispatchContext[TransportContext]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_base_context_forwards_transport_and_cancel_requested():
|
|
captured: list[BaseContext[TransportContext]] = []
|
|
open_while_handling: list[bool] = []
|
|
|
|
async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
bctx = BaseContext(ctx)
|
|
captured.append(bctx)
|
|
# `can_send_request` is sampled in-handler: once the request returns the
|
|
# dispatch context closes and it becomes False (covered by the sibling below).
|
|
open_while_handling.append(bctx.can_send_request)
|
|
return {}
|
|
|
|
async with running_pair(direct_pair, server_on_request=server_on_request) as (client, *_):
|
|
with anyio.fail_after(5):
|
|
await client.send_raw_request("t", None)
|
|
bctx = captured[0]
|
|
assert bctx.transport.kind == "direct"
|
|
assert isinstance(bctx.cancel_requested, anyio.Event)
|
|
assert open_while_handling == [True]
|
|
assert bctx.meta is None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_base_context_can_send_request_reflects_dispatch_context_closed_state(pair_factory: PairFactory):
|
|
"""`can_send_request` must track the dctx, not the static transport flag,
|
|
so it agrees with whether `send_raw_request` would raise.
|
|
|
|
Parametrized over both dispatchers: the `DispatchContext` Protocol promises this for
|
|
every implementation, so the two must not be allowed to drift apart on it.
|
|
"""
|
|
captured: list[BaseContext[TransportContext]] = []
|
|
|
|
async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
captured.append(BaseContext(ctx))
|
|
return {}
|
|
|
|
async with running_pair(pair_factory, server_on_request=server_on_request) as (client, *_):
|
|
with anyio.fail_after(5):
|
|
await client.send_raw_request("t", None)
|
|
bctx = captured[0]
|
|
assert bctx.transport.can_send_request is True
|
|
assert bctx.can_send_request is False
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_base_context_send_raw_request_and_notify_forward_to_dispatch_context():
|
|
crec = Recorder()
|
|
c_req, c_notify = echo_handlers(crec)
|
|
|
|
async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
bctx = BaseContext(ctx)
|
|
sample = await bctx.send_raw_request("sampling/createMessage", {"x": 1})
|
|
await bctx.notify("notifications/message", {"level": "info"})
|
|
return {"sample": sample}
|
|
|
|
async with running_pair(
|
|
direct_pair,
|
|
server_on_request=server_on_request,
|
|
client_on_request=c_req,
|
|
client_on_notify=c_notify,
|
|
) as (client, *_):
|
|
with anyio.fail_after(5):
|
|
result = await client.send_raw_request("tools/call", None)
|
|
await crec.notified.wait()
|
|
assert crec.requests == [("sampling/createMessage", {"x": 1})]
|
|
assert crec.notifications == [("notifications/message", {"level": "info"})]
|
|
assert result["sample"] == {"echoed": "sampling/createMessage", "params": {"x": 1}}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_base_context_report_progress_invokes_caller_on_progress():
|
|
received: list[tuple[float, float | None, str | None]] = []
|
|
|
|
async def on_progress(progress: float, total: float | None, message: str | None) -> None:
|
|
received.append((progress, total, message))
|
|
|
|
async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
bctx = BaseContext(ctx)
|
|
await bctx.report_progress(0.5, total=1.0, message="halfway")
|
|
return {}
|
|
|
|
async with running_pair(direct_pair, server_on_request=server_on_request) as (client, *_):
|
|
with anyio.fail_after(5):
|
|
await client.send_raw_request("t", None, {"on_progress": on_progress})
|
|
assert received == [(0.5, 1.0, "halfway")]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_base_context_satisfies_outbound_so_peer_mixin_works():
|
|
"""Wrapping a BaseContext in ClientPeer proves it satisfies Outbound structurally."""
|
|
|
|
async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
bctx = BaseContext(ctx)
|
|
await ClientPeer(bctx).ping()
|
|
return {}
|
|
|
|
crec = Recorder()
|
|
c_req, c_notify = echo_handlers(crec)
|
|
async with running_pair(
|
|
direct_pair, server_on_request=server_on_request, client_on_request=c_req, client_on_notify=c_notify
|
|
) as (client, *_):
|
|
with anyio.fail_after(5):
|
|
await client.send_raw_request("t", None)
|
|
assert crec.requests == [("ping", None)]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_base_context_meta_holds_supplied_request_params_meta():
|
|
async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
bctx = BaseContext(ctx, meta={"progressToken": "abc"})
|
|
assert bctx.meta is not None and bctx.meta.get("progressToken") == "abc"
|
|
return {}
|
|
|
|
async with running_pair(direct_pair, server_on_request=server_on_request) as (client, *_):
|
|
with anyio.fail_after(5):
|
|
await client.send_raw_request("t", None)
|