Skip to content

Commit 98d9dd0

Browse files
committed
Bound the re-list by the caller's timeout and chain every re-list failure
`read_timeout_seconds` now covers the whole re-list after a `HeaderMismatch`, which otherwise ran on the session default. When it elapses, or a `tools/list` page fails validation, the original `-32020` is raised with that failure as its cause.
1 parent fe11e39 commit 98d9dd0

2 files changed

Lines changed: 114 additions & 3 deletions

File tree

‎src/mcp/client/client.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
ServerCapabilities,
4242
)
4343
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS
44+
from pydantic import ValidationError
4445
from typing_extensions import deprecated
4546

4647
from mcp.client._input_required import DEFAULT_INPUT_REQUIRED_MAX_ROUNDS, run_input_required_driver
@@ -787,7 +788,8 @@ async def call_tool(
787788
Args:
788789
name: The name of the tool to call.
789790
arguments: Arguments to pass to the tool.
790-
read_timeout_seconds: Timeout for each underlying `tools/call` round.
791+
read_timeout_seconds: Timeout for each underlying `tools/call` round, and
792+
for the whole re-list after a `HEADER_MISMATCH`.
791793
progress_callback: Callback for progress updates.
792794
input_responses: Responses to seed the first call with (e.g. when
793795
resuming from a persisted `InputRequiredResult`).
@@ -826,8 +828,9 @@ async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | Inp
826828
raise
827829
# The spec's recovery: the tool's listed schema is missing or stale, so re-list and resend once.
828830
try:
829-
await self._relist_tool(name)
830-
except MCPError as relist_error:
831+
with anyio.fail_after(read_timeout_seconds):
832+
await self._relist_tool(name)
833+
except (MCPError, TimeoutError, ValidationError) as relist_error:
831834
raise mismatch from relist_error
832835
return await send(r, s)
833836

‎tests/interaction/transports/test_hosting_http_modern.py‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,18 +42,26 @@
4242
Tool,
4343
)
4444
from mcp_types.version import LATEST_MODERN_VERSION
45+
from pydantic import ValidationError
46+
from trio.testing import MockClock
4547

4648
from mcp import MCPError
4749
from mcp.client.client import Client
4850
from mcp.client.session import ClientSession
4951
from mcp.client.streamable_http import streamable_http_client
5052
from mcp.server import Server, ServerRequestContext
53+
from mcp.server.context import CallNext, HandlerResult
5154
from tests.interaction._connect import BASE_URL, base_headers, initialize_via_http, mounted_app
5255
from tests.interaction._requirements import requirement
5356

5457
pytestmark = pytest.mark.anyio
5558

5659

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+
5765
def _modern_headers(*, method: str, name: str | None = None) -> dict[str, str]:
5866
"""Request headers for a 2026-07-28 POST.
5967
@@ -778,6 +786,106 @@ async def rewrite_mcp_name(request: httpx2.Request) -> None:
778786
assert methods == ["tools/call", "tools/list"]
779787

780788

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+
781889
@requirement("client-transport:http:header-mismatch-recovery")
782890
async def test_legacy_client_raises_a_header_mismatch_error_without_re_listing_or_retrying() -> None:
783891
"""On a pre-2026 connection a `-32020` error from a tool call is raised as it arrives.

0 commit comments

Comments
 (0)