Skip to content
14 changes: 7 additions & 7 deletions docs/get-started/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,18 @@ Let's assume you have a simple server with a single tool:
--8<-- "docs_src/testing/tutorial001.py"
```

To run the test below you'll need two extra (development) dependencies:
Install the development dependencies to run the test on both async backends:

=== "uv"

```bash
uv add --dev pytest inline-snapshot
uv add --dev pytest inline-snapshot trio
```

=== "pip"

```bash
pip install pytest inline-snapshot
pip install pytest inline-snapshot trio
```

!!! info
Expand All @@ -45,9 +45,9 @@ from mcp.types import CallToolResult, TextContent
from server import mcp


@pytest.fixture
def anyio_backend(): # (1)!
return "asyncio"
@pytest.fixture(params=["asyncio", "trio"])
def anyio_backend(request: pytest.FixtureRequest) -> str: # (1)!
return request.param


@pytest.fixture
Expand All @@ -69,7 +69,7 @@ async def test_call_add_tool(client: Client):
)
```

1. If you are using `trio`, return `"trio"` instead. See the [anyio documentation](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on) for the details.
1. Each test runs once with `asyncio` and once with `trio`. Testing both catches backend-specific assumptions and scheduling races. If your application requires one backend, keep only that name in `params`. See the [anyio documentation](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on) for details.
2. The fixture yields a connected client. Every test that takes `client` gets a fresh in-memory connection to the same server.

There you go! You can now extend your tests to cover more scenarios.
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,8 @@ filterwarnings = [
# CI and scripts/test set PYTHONWARNDEFAULTENCODING=1, so "error" rejects any text I/O
# of ours that omits encoding=; pytest-examples' own unguarded text I/O isn't ours.
"ignore:'encoding' argument not specified:EncodingWarning:pytest_examples",
# Trio wraps Linux pidfds in text mode without encoding=; only fileno()/close() are used.
"ignore:'encoding' argument not specified:EncodingWarning:trio\\._subprocess$",
]

[tool.markdown.lint]
Expand Down
9 changes: 8 additions & 1 deletion tests/client/test_stdio.py
Original file line number Diff line number Diff line change
Expand Up @@ -905,9 +905,16 @@ async def test_invalid_utf8_flushed_by_a_dying_server_does_not_break_shutdown(
abort the drain or surface a UnicodeDecodeError out of the context manager.
"""
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
process = FakeProcess(on_stdin_close=lambda: process.exit(0))
process = FakeProcess()
terminated = install_fake_process(monkeypatch, process)

def exit_when_flushed() -> None:
if process.stdin_closed.is_set() and process.pending_stdout_chunks() == 0:
process.exit(0)

process.on_stdin_close = exit_when_flushed
process.on_stdout_receive = exit_when_flushed

with anyio.fail_after(5):
async with stdio_client(FAKE_PARAMS):
# Park the reader delivering a message nobody receives, then queue
Expand Down
14 changes: 7 additions & 7 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@
del _httpcore2


@pytest.fixture(scope="session")
def anyio_backend() -> str:
return "asyncio"
@pytest.fixture(scope="session", params=["asyncio", "trio"])
def anyio_backend(request: pytest.FixtureRequest) -> str:
return request.param


@pytest.fixture(autouse=True)
Expand All @@ -51,7 +51,7 @@ def blockbuster() -> Iterator[None]:

@pytest.fixture(scope="module", autouse=True)
async def _module_runner_lease(anyio_backend: str) -> AsyncIterator[None]:
"""Share one event loop across each module's tests instead of one per test.
"""Share one event loop per module and backend instead of one per test.

anyio's pytest plugin tears its runner down whenever the last lease is
released, so with only function-scoped async fixtures every async test
Expand All @@ -60,15 +60,15 @@ async def _module_runner_lease(anyio_backend: str) -> AsyncIterator[None]:
run can transiently exhaust kernel socket buffers — surfacing in CI as
`OSError: [WinError 10055]` raised from `asyncio.new_event_loop()` before
an arbitrary test's body even starts. Holding a module-scoped lease caps
the churn at one loop per module per xdist worker.
the churn at one loop per module and backend per xdist worker.

Modules that parametrize `anyio_backend` or call `trio.run(...)` directly
must shadow this fixture with a sync no-op: a module-scoped lease cannot
depend on the function-scoped parameter (pytest raises ScopeMismatch at
setup), and the lease's live asyncio loop lingers over direct trio runs,
whose signal handling collides with the loop's wakeup fd on Windows. The
lease also makes sniffio report asyncio to the module's sync tests, so a
sync test must not call `anyio.run()` itself.
lease also makes sniffio report the leased backend to the module's sync
tests, so a sync test must not call `anyio.run()` itself.
"""
yield

Expand Down
4 changes: 4 additions & 0 deletions tests/docs_src/test_asgi.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""`docs/run/asgi.md`: every claim the page makes, proved against the real SDK."""

import inspect
from importlib import reload

import httpx2
import pytest
Expand Down Expand Up @@ -113,6 +114,7 @@ async def about(request: Request) -> Response:

async def test_the_host_lifespan_enters_the_session_manager() -> None:
"""tutorial002: the host app's lifespan owns `session_manager.run()` and starts and stops cleanly."""
reload(tutorial002)
async with tutorial002.lifespan(tutorial002.app):
async with Client(tutorial002.mcp) as client:
result = await client.call_tool("add_note", {"text": "milk"})
Expand All @@ -130,6 +132,7 @@ async def test_two_servers_get_two_mounts() -> None:

async def test_one_lifespan_starts_both_session_managers() -> None:
"""tutorial003: a single `AsyncExitStack` lifespan runs both managers; both servers answer."""
reload(tutorial003)
async with tutorial003.lifespan(tutorial003.app):
async with Client(tutorial003.notes) as client:
notes_result = await client.call_tool("add_note", {"text": "milk"})
Expand Down Expand Up @@ -215,6 +218,7 @@ async def test_the_default_app_is_localhost_only() -> None:
async def test_the_documented_browser_origin_works_end_to_end() -> None:
"""tutorial005: the page's scenario for real. The public hostname, the browser origin, a
realistic preflight naming the `Mcp-*` headers, then the actual request."""
reload(tutorial005)
transport = httpx2.ASGITransport(app=tutorial005.app)
async with tutorial005.lifespan(tutorial005.app):
async with httpx2.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
Expand Down
2 changes: 2 additions & 0 deletions tests/docs_src/test_context.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""`docs/handlers/context.md`: every claim the page makes, proved against the real SDK."""

import re
from importlib import reload

import pytest
from inline_snapshot import snapshot
Expand Down Expand Up @@ -62,6 +63,7 @@ async def test_a_context_only_tool_takes_no_arguments() -> None:

async def test_register_a_tool_at_runtime_and_notify_the_client() -> None:
"""tutorial003: `mcp.add_tool` takes effect immediately and `send_tool_list_changed` reaches the client."""
reload(tutorial003)
messages: list[object] = []

async def collect(message: object) -> None:
Expand Down
3 changes: 3 additions & 0 deletions tests/docs_src/test_deploy.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""`docs/run/deploy.md`: every claim the page makes, proved against the real SDK."""

from importlib import reload

import anyio
import httpx2
import pytest
Expand Down Expand Up @@ -51,6 +53,7 @@ async def test_the_default_app_rejects_a_real_hostname_before_mcp_runs() -> None

async def test_the_allowlisted_app_serves_its_hostname_and_still_rejects_others() -> None:
"""tutorial001: `allowed_hosts=` opens exactly the hostname you named, and nothing else."""
reload(tutorial001)
transport = httpx2.ASGITransport(app=tutorial001.app)
async with tutorial001.mcp.session_manager.run():
async with httpx2.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
Expand Down
2 changes: 2 additions & 0 deletions tests/docs_src/test_identity_assertion.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""`docs/client/identity-assertion.md`: every claim the page makes, proved against the real SDK."""

import inspect
from importlib import reload
from urllib.parse import parse_qsl

import httpx2
Expand Down Expand Up @@ -144,6 +145,7 @@ async def test_the_metadata_advertises_the_grant_type_and_the_id_jag_profile() -

async def test_the_whole_grant_is_one_token_request() -> None:
"""The `!!! check`: a 401, the well-known fetch, one `POST /token`, the retry; the subject reaches the tool."""
reload(tutorial001)
mcp = MCPServer(
"Notes",
token_verifier=ProviderTokenVerifier(tutorial002.provider),
Expand Down
2 changes: 2 additions & 0 deletions tests/docs_src/test_legacy_clients.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""`docs/run/legacy-clients.md`: every claim the page makes, proved against the real SDK."""

import inspect
from importlib import reload

import httpx2
import pytest
Expand Down Expand Up @@ -115,6 +116,7 @@ async def test_stateless_http_never_mints_a_session() -> None:
async def test_stateless_http_kills_the_legacy_back_channel_and_only_the_legacy_one() -> None:
"""tutorial002: over the same `stateless_http=True` app, the modern client still gets its answer and
the legacy client's call fails as the top-level `MCPError` the `!!! check` quotes."""
reload(tutorial002)
async with (
tutorial002.app.router.lifespan_context(tutorial002.app),
httpx2.ASGITransport(tutorial002.app) as transport,
Expand Down
87 changes: 50 additions & 37 deletions tests/docs_src/test_subscriptions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""`docs/{handlers,client}/subscriptions.md`: every claim the two pages make, proved against the real SDK."""

from collections.abc import Awaitable, Callable
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any

import anyio
Expand All @@ -19,18 +20,14 @@
tutorial006,
)
from mcp import Client
from mcp.client.subscriptions import Subscription
from mcp.server.auth.middleware.auth_context import auth_context_var
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
from mcp.server.auth.provider import AccessToken
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, ListenHandler, ToolsListChanged
from mcp.shared.exceptions import MCPError

_ReadResource = Callable[
[ServerRequestContext[Any], types.ReadResourceRequestParams], Awaitable[types.ReadResourceResult]
]

# 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")]

Expand Down Expand Up @@ -68,24 +65,18 @@ async def wait_for(self, count: int) -> None:
await self._arrival.wait()


class _Reads:
"""Counts server-side resource reads so a test can await the Nth refetch."""
class _Output:
"""Counts completed prints so tests wait for client output, not server-side reads."""

def __init__(self) -> None:
self.count = 0
self._bump = anyio.Event()

def counting(self, handler: _ReadResource) -> _ReadResource:
async def counted(
ctx: ServerRequestContext[Any], params: types.ReadResourceRequestParams
) -> types.ReadResourceResult:
result = await handler(ctx, params)
self.count += 1
self._bump.set()
self._bump = anyio.Event()
return result

return counted
def __call__(self, *values: str | list[str]) -> None:
print(*values)
self.count += 1
self._bump.set()
self._bump = anyio.Event()

async def wait_for(self, count: int) -> None:
with anyio.fail_after(5):
Expand Down Expand Up @@ -209,18 +200,34 @@ async def listen() -> None:


async def test_follow_board_prints_the_refetched_board_and_the_new_tool_list(
capsys: pytest.CaptureFixture[str],
capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
"""tutorial003: each event drives a refetch - the board reprints, and a tools change reprints the tool names."""
output = _Output()
monkeypatch.setattr(tutorial003, "print", output, raising=False)
listening = anyio.Event()
async with Client(tutorial001.mcp) as client:
listen = client.listen

@asynccontextmanager
async def listen_when_ready(
*, tools_list_changed: bool, resource_subscriptions: list[str]
) -> AsyncIterator[Subscription]:
async with listen(
tools_list_changed=tools_list_changed, resource_subscriptions=resource_subscriptions
) as sub:
listening.set()
yield sub

monkeypatch.setattr(client, "listen", listen_when_ready)
async with anyio.create_task_group() as tg:
tg.start_soon(tutorial003.follow_board, client)
# Let the watcher park on its stream (ack complete) before publishing.
await anyio.wait_all_tasks_blocked()
with anyio.fail_after(5):
await listening.wait()
await client.call_tool("complete_task", {"board": "sprint", "task": "design"})
await anyio.wait_all_tasks_blocked()
await output.wait_for(1)
await client.call_tool("enable_reports", {})
await anyio.wait_all_tasks_blocked()
await output.wait_for(2)
tg.cancel_scope.cancel()

printed = capsys.readouterr().out
Expand All @@ -243,45 +250,52 @@ def _assert_snapshot_then_current_board(printed: str) -> None:
assert printed.strip().endswith(FINISHED_BOARD), printed


@pytest.mark.parametrize("anyio_backend", [pytest.param("asyncio", id="asyncio")])
async def test_the_asyncio_watcher_runs_beside_the_main_flow(capsys: pytest.CaptureFixture[str]) -> None:
"""tutorial004 (asyncio tab): run_sprint opens the subscription, snapshots the board, then a watcher
task reprints it while the main flow keeps calling tools.

The example connects over HTTP; the in-memory client here is the maintainer-side stand-in."""
async with Client(tutorial001.mcp) as client:
await tutorial004_asyncio.run_sprint(client)
with anyio.fail_after(5):
await tutorial004_asyncio.run_sprint(client)
_assert_snapshot_then_current_board(capsys.readouterr().out)


@pytest.mark.parametrize("anyio_backend", [pytest.param("trio", id="trio")])
async def test_the_trio_watcher_runs_beside_the_main_flow(capsys: pytest.CaptureFixture[str]) -> None:
"""tutorial004 (trio tab): the same shape as the asyncio tab, with a nursery owning the watcher."""
async with Client(tutorial001.mcp) as client:
await tutorial004_trio.run_sprint(client)
with anyio.fail_after(5):
await tutorial004_trio.run_sprint(client)
_assert_snapshot_then_current_board(capsys.readouterr().out)


async def test_the_anyio_watcher_runs_beside_the_main_flow(capsys: pytest.CaptureFixture[str]) -> None:
"""tutorial004 (anyio tab): the same shape again, with a task group owning the watcher."""
async with Client(tutorial001.mcp) as client:
await tutorial004_anyio.run_sprint(client)
with anyio.fail_after(5):
await tutorial004_anyio.run_sprint(client)
_assert_snapshot_then_current_board(capsys.readouterr().out)


@pytest.mark.parametrize(
"anyio_backend",
[pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")],
)
async def test_the_follower_re_listens_after_the_stream_ends(capsys: pytest.CaptureFixture[str]) -> None:
async def test_the_follower_re_listens_after_the_stream_ends(
capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
"""tutorial005: a graceful server close ends one stream; the loop backs off, re-listens, and refetches.

Runs on trio's autojumping MockClock so the loop's backoff sleep takes no wall-clock time.
"""
reads = _Reads()
output = _Output()
monkeypatch.setattr(tutorial005, "print", output, raising=False)
handler = ListenHandler(tutorial002.bus)
server = Server(
"sprint-board",
on_read_resource=reads.counting(tutorial002.read_resource),
on_read_resource=tutorial002.read_resource,
on_list_tools=tutorial002.list_tools,
on_call_tool=tutorial002.call_tool,
on_subscriptions_listen=handler,
Expand All @@ -290,17 +304,16 @@ async def test_the_follower_re_listens_after_the_stream_ends(capsys: pytest.Capt
async with Client(server) as client:
async with anyio.create_task_group() as tg:
tg.start_soon(tutorial005.keep_following, client)
# First stream: the entry refetch reads the board, then an event reads it again.
await reads.wait_for(1)
# First stream: print the entry snapshot, then the board after the event.
await output.wait_for(1)
await client.call_tool("complete_task", {"task": "design"})
await reads.wait_for(2)
await output.wait_for(2)

# End that stream gracefully. The loop backs off (the mock clock jumps the
# sleep), re-listens, and refetches on entry: that is the third read.
# The mock clock jumps the backoff; the third print is the new stream's snapshot.
handler.close()
await reads.wait_for(3)
await output.wait_for(3)
await client.call_tool("complete_task", {"task": "build"})
await reads.wait_for(4)
await output.wait_for(4)
tg.cancel_scope.cancel()

printed = capsys.readouterr().out
Expand Down
Loading
Loading