"""`docs/handlers/progress.md`: every claim the page makes, proved against the real SDK.""" import inspect import anyio import pytest from mcp_types import TextContent from docs_src.progress import tutorial001, tutorial002 from mcp import Client # See test_index.py for why this is a per-module mark and not a conftest hook. pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] URLS = ["https://example.com/a.json", "https://example.com/b.json"] async def test_context_parameter_is_invisible_to_the_model() -> None: """tutorial001: `ctx` comes from the type hint and never reaches the input schema.""" async with Client(tutorial001.mcp) as client: (tool,) = (await client.list_tools()).tools assert tool.input_schema["properties"] == { "urls": {"items": {"type": "string"}, "title": "Urls", "type": "array"} } assert tool.input_schema["required"] == ["urls"] async def test_each_report_becomes_one_callback_invocation_in_order() -> None: """tutorial001: `progress_callback` receives every `(progress, total, message)` the tool reported.""" updates: list[tuple[float, float | None, str | None]] = [] async def show(progress: float, total: float | None, message: str | None) -> None: updates.append((progress, total, message)) async with Client(tutorial001.mcp) as client: result = await client.call_tool("import_catalog", {"urls": URLS}, progress_callback=show) assert updates == [ (1, 2, "Imported https://example.com/a.json"), (2, 2, "Imported https://example.com/b.json"), ] assert result.content == [TextContent(type="text", text="Imported 2 records.")] assert result.structured_content == {"result": "Imported 2 records."} async def test_over_a_wire_dispatcher_callbacks_race_the_result() -> None: """The `!!! info`: only the in-memory connection runs the callback inline. On a wire dispatcher (`mode="legacy"` here) each progress notification starts its own task, so `call_tool` can return while a slow callback is still running. The callbacks below block on an event that is only set *after* `call_tool` has returned: exactly the situation the page tells you not to rule out. """ release = anyio.Event() done = anyio.Event() finished: list[float] = [] async def gated(progress: float, total: float | None, message: str | None) -> None: await release.wait() finished.append(progress) if len(finished) == 2: done.set() async with Client(tutorial001.mcp, mode="legacy") as client: with anyio.fail_after(5): result = await client.call_tool("import_catalog", {"urls": URLS}, progress_callback=gated) assert finished == [] release.set() with anyio.fail_after(5): await done.wait() assert sorted(finished) == [1, 2] assert result.structured_content == {"result": "Imported 2 records."} async def test_without_a_callback_report_progress_is_a_no_op() -> None: """The `!!! check`: omit `progress_callback` and the tool runs to the same result, no error.""" async with Client(tutorial001.mcp) as client: result = await client.call_tool("import_catalog", {"urls": URLS}) assert not result.is_error assert result.structured_content == {"result": "Imported 2 records."} def test_progress_callback_is_per_call_not_per_client() -> None: """The `!!! warning`: `call_tool` takes `progress_callback`; the `Client` constructor does not.""" assert "progress_callback" in inspect.signature(Client.call_tool).parameters assert "progress_callback" not in inspect.signature(Client.__init__).parameters async def test_omitting_total_reaches_the_callback_as_none() -> None: """tutorial002: a report without `total` arrives as `total=None`: activity, not a percentage.""" updates: list[tuple[float, float | None, str | None]] = [] async def show(progress: float, total: float | None, message: str | None) -> None: updates.append((progress, total, message)) async with Client(tutorial002.mcp) as client: result = await client.call_tool("import_feed", {"feed_url": "https://example.com/feed"}, progress_callback=show) assert updates == [ (1, None, "Imported https://example.com/feed#Dune"), (2, None, "Imported https://example.com/feed#Neuromancer"), (3, None, "Imported https://example.com/feed#Hyperion"), ] assert result.structured_content == {"result": "Imported 3 records."}