|
42 | 42 | Tool, |
43 | 43 | ) |
44 | 44 | from mcp_types.version import LATEST_MODERN_VERSION |
| 45 | +from pydantic import ValidationError |
| 46 | +from trio.testing import MockClock |
45 | 47 |
|
46 | 48 | from mcp import MCPError |
47 | 49 | from mcp.client.client import Client |
48 | 50 | from mcp.client.session import ClientSession |
49 | 51 | from mcp.client.streamable_http import streamable_http_client |
50 | 52 | from mcp.server import Server, ServerRequestContext |
| 53 | +from mcp.server.context import CallNext, HandlerResult |
51 | 54 | from tests.interaction._connect import BASE_URL, base_headers, initialize_via_http, mounted_app |
52 | 55 | from tests.interaction._requirements import requirement |
53 | 56 |
|
54 | 57 | pytestmark = pytest.mark.anyio |
55 | 58 |
|
56 | 59 |
|
| 60 | +@pytest.fixture(autouse=True) |
| 61 | +def _module_runner_lease() -> None: |
| 62 | + """Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`.""" |
| 63 | + |
| 64 | + |
57 | 65 | def _modern_headers(*, method: str, name: str | None = None) -> dict[str, str]: |
58 | 66 | """Request headers for a 2026-07-28 POST. |
59 | 67 |
|
@@ -778,6 +786,106 @@ async def rewrite_mcp_name(request: httpx2.Request) -> None: |
778 | 786 | assert methods == ["tools/call", "tools/list"] |
779 | 787 |
|
780 | 788 |
|
| 789 | +@requirement("client-transport:http:header-mismatch-recovery") |
| 790 | +async def test_modern_client_raises_the_header_mismatch_when_the_re_list_returns_a_malformed_page() -> None: |
| 791 | + """A `tools/list` page that fails validation leaves the caller with the server's `HeaderMismatch`. |
| 792 | +
|
| 793 | + SDK-defined: a caller's `except MCPError` still sees the rejection, with the `ValidationError` as its |
| 794 | + cause, and the call is not resent. The page comes from a middleware that answers without `call_next`, |
| 795 | + the one place the SDK server does not validate an outgoing result. |
| 796 | + """ |
| 797 | + |
| 798 | + async def malformed_listing(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult: |
| 799 | + assert ctx.method == "tools/list" |
| 800 | + return {"tools": "not a list"} |
| 801 | + |
| 802 | + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: |
| 803 | + raise NotImplementedError |
| 804 | + |
| 805 | + server = Server("malformed", on_call_tool=call_tool) |
| 806 | + server.middleware.append(malformed_listing) |
| 807 | + |
| 808 | + methods: list[str] = [] |
| 809 | + |
| 810 | + async def rewrite_mcp_name(request: httpx2.Request) -> None: |
| 811 | + method = json.loads(request.content)["method"] |
| 812 | + methods.append(method) |
| 813 | + if method == "tools/call": |
| 814 | + request.headers["mcp-name"] = "another-tool" |
| 815 | + |
| 816 | + discover = DiscoverResult( |
| 817 | + supported_versions=[LATEST_MODERN_VERSION], |
| 818 | + capabilities=ServerCapabilities(), |
| 819 | + ) |
| 820 | + async with ( |
| 821 | + mounted_app(server, on_request=rewrite_mcp_name) as (http, _), |
| 822 | + Client( |
| 823 | + streamable_http_client(f"{BASE_URL}/mcp", http_client=http), |
| 824 | + mode=LATEST_MODERN_VERSION, |
| 825 | + prior_discover=discover, |
| 826 | + ) as client, |
| 827 | + ): |
| 828 | + with anyio.fail_after(5), pytest.raises(MCPError) as excinfo: |
| 829 | + await client.call_tool("run", {"region": "us-west1"}) |
| 830 | + |
| 831 | + assert excinfo.value.error.code == HEADER_MISMATCH |
| 832 | + assert isinstance(excinfo.value.__cause__, ValidationError) |
| 833 | + assert methods == ["tools/call", "tools/list"] |
| 834 | + |
| 835 | + |
| 836 | +# The timeout also governs the rejected `tools/call`, which must be answered before the re-list can |
| 837 | +# wait it out, so any real-clock value is a bet against CI scheduler stalls. On trio's autojumping |
| 838 | +# clock time advances only when every task is blocked: the answered call cannot time out however slow |
| 839 | +# the runner, and once the re-list blocks the clock jumps straight to the deadline, with no real wait. |
| 840 | +@requirement("client-transport:http:header-mismatch-recovery") |
| 841 | +@pytest.mark.parametrize( |
| 842 | + "anyio_backend", |
| 843 | + [pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")], |
| 844 | +) |
| 845 | +async def test_modern_client_raises_the_header_mismatch_when_the_re_list_outlasts_the_read_timeout() -> None: |
| 846 | + """The caller's `read_timeout_seconds` bounds the re-list, which otherwise has no timeout of its own. |
| 847 | +
|
| 848 | + SDK-defined: the server rejects the call and then never answers `tools/list`. When the timeout elapses |
| 849 | + the rejection is raised with the `TimeoutError` as its cause, and the call is not resent. |
| 850 | + """ |
| 851 | + |
| 852 | + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: |
| 853 | + await anyio.Event().wait() # blocks until the abandoned request's disconnect interrupts it |
| 854 | + raise NotImplementedError # unreachable |
| 855 | + |
| 856 | + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: |
| 857 | + raise NotImplementedError |
| 858 | + |
| 859 | + server = Server("stalled", on_list_tools=list_tools, on_call_tool=call_tool) |
| 860 | + |
| 861 | + methods: list[str] = [] |
| 862 | + |
| 863 | + async def rewrite_mcp_name(request: httpx2.Request) -> None: |
| 864 | + method = json.loads(request.content)["method"] |
| 865 | + methods.append(method) |
| 866 | + if method == "tools/call": |
| 867 | + request.headers["mcp-name"] = "another-tool" |
| 868 | + |
| 869 | + discover = DiscoverResult( |
| 870 | + supported_versions=[LATEST_MODERN_VERSION], |
| 871 | + capabilities=ServerCapabilities(), |
| 872 | + ) |
| 873 | + async with ( |
| 874 | + mounted_app(server, on_request=rewrite_mcp_name) as (http, _), |
| 875 | + Client( |
| 876 | + streamable_http_client(f"{BASE_URL}/mcp", http_client=http), |
| 877 | + mode=LATEST_MODERN_VERSION, |
| 878 | + prior_discover=discover, |
| 879 | + ) as client, |
| 880 | + ): |
| 881 | + with anyio.fail_after(5), pytest.raises(MCPError) as excinfo: |
| 882 | + await client.call_tool("run", {"region": "us-west1"}, read_timeout_seconds=0.05) |
| 883 | + |
| 884 | + assert excinfo.value.error.code == HEADER_MISMATCH |
| 885 | + assert isinstance(excinfo.value.__cause__, TimeoutError) |
| 886 | + assert methods == ["tools/call", "tools/list"] |
| 887 | + |
| 888 | + |
781 | 889 | @requirement("client-transport:http:header-mismatch-recovery") |
782 | 890 | async def test_legacy_client_raises_a_header_mismatch_error_without_re_listing_or_retrying() -> None: |
783 | 891 | """On a pre-2026 connection a `-32020` error from a tool call is raised as it arrives. |
|
0 commit comments