Client-side 2026-07-28 support: .discover()/.adopt() + Client(mode=); request-metadata green (#2950)

This commit is contained in:
Max 2026-06-25 16:09:23 +02:00 • committed by GitHub
commit f226d00d0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 2533 additions and 993 deletions

View file

@ -17,11 +17,10 @@ from mcp.shared.inbound import (
InboundModernRoute,
classify_inbound_request,
)
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
from mcp.shared.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS
from mcp.types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
LATEST_PROTOCOL_VERSION,
PROTOCOL_VERSION_META_KEY,
)
from mcp.types.jsonrpc import (
@ -34,9 +33,6 @@ from mcp.types.jsonrpc import (
UNSUPPORTED_PROTOCOL_VERSION,
)
MODERN = MODERN_PROTOCOL_VERSIONS[0]
"""The modern protocol-version string, read from the registry — never inlined here."""
CLIENT_INFO = {"name": "t", "version": "0"}
CLIENT_CAPS: dict[str, Any] = {}
@ -44,7 +40,7 @@ CLIENT_CAPS: dict[str, Any] = {}
def envelope(
method: str = "tools/list",
*,
version: str = MODERN,
version: str = LATEST_MODERN_VERSION,
drop: frozenset[str] = frozenset(),
) -> dict[str, Any]:
"""Build a JSON-RPC body carrying a complete modern ``_meta`` envelope.
@ -108,23 +104,23 @@ def test_envelope_rung_rejects_non_mapping_shapes(body: dict[str, Any]) -> None:
def test_version_rung_rejects_unsupported_with_data_shape() -> None:
"""Spec-mandated: an envelope version outside the modern set rejects with the ``supported``/``requested`` data."""
rejection = assert_rejected(
classify_inbound_request(envelope(version=LATEST_PROTOCOL_VERSION)),
classify_inbound_request(envelope(version=LATEST_HANDSHAKE_VERSION)),
UNSUPPORTED_PROTOCOL_VERSION,
)
assert rejection.data == {
"supported": list(MODERN_PROTOCOL_VERSIONS),
"requested": LATEST_PROTOCOL_VERSION,
"requested": LATEST_HANDSHAKE_VERSION,
}
def test_version_rung_data_reflects_supplied_supported_list() -> None:
"""SDK-defined: the caller-supplied ``supported_modern_versions`` is what rejection ``data.supported`` echoes."""
custom = (LATEST_PROTOCOL_VERSION,)
custom = (LATEST_HANDSHAKE_VERSION,)
rejection = assert_rejected(
classify_inbound_request(envelope(), supported_modern_versions=custom),
UNSUPPORTED_PROTOCOL_VERSION,
)
assert rejection.data == {"supported": list(custom), "requested": MODERN}
assert rejection.data == {"supported": list(custom), "requested": LATEST_MODERN_VERSION}
# --- rung 3: header ↔ envelope agreement ---------------------------------------
@ -138,14 +134,14 @@ def test_header_rung_does_not_reject_when_headers_arg_is_none() -> None:
def test_header_rung_passes_when_header_matches_envelope() -> None:
"""Spec-mandated: an HTTP version header equal to the envelope version passes rung 3."""
result = classify_inbound_request(envelope(), headers={MCP_PROTOCOL_VERSION_HEADER: MODERN})
result = classify_inbound_request(envelope(), headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION})
assert isinstance(result, InboundModernRoute)
@pytest.mark.parametrize(
"headers",
[
pytest.param({MCP_PROTOCOL_VERSION_HEADER: LATEST_PROTOCOL_VERSION}, id="mismatch"),
pytest.param({MCP_PROTOCOL_VERSION_HEADER: LATEST_HANDSHAKE_VERSION}, id="mismatch"),
pytest.param({}, id="header-absent"),
],
)
@ -159,9 +155,9 @@ def test_header_rung_rejects_on_disagreement(headers: dict[str, str]) -> None:
def test_all_rungs_pass_yields_route() -> None:
"""Spec-mandated: a complete envelope at a supported version with agreeing header routes, surfacing the envelope."""
result = classify_inbound_request(envelope(), headers={MCP_PROTOCOL_VERSION_HEADER: MODERN})
result = classify_inbound_request(envelope(), headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION})
assert isinstance(result, InboundModernRoute)
assert result.protocol_version == MODERN
assert result.protocol_version == LATEST_MODERN_VERSION
assert result.client_info == CLIENT_INFO
assert result.client_capabilities == CLIENT_CAPS
@ -169,7 +165,7 @@ def test_all_rungs_pass_yields_route() -> None:
@pytest.mark.parametrize("method", ["initialize", "myorg/custom", "does/not/exist"])
def test_classifier_passes_unknown_method_through_to_route(method: str) -> None:
"""SDK-defined: the classifier does not gate on method — kernel dispatch is the single owner of that decision."""
result = classify_inbound_request(envelope(method), headers={MCP_PROTOCOL_VERSION_HEADER: MODERN})
result = classify_inbound_request(envelope(method), headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION})
assert isinstance(result, InboundModernRoute)
@ -177,8 +173,8 @@ def test_ladder_first_failure_wins() -> None:
"""Spec-mandated: rungs evaluate in order — header-mismatch and version-unsupported
would both fail; the header rung fires first so an inconsistent client is told it
disagrees with itself rather than that its body version is unsupported."""
body = envelope(version=LATEST_PROTOCOL_VERSION)
result = classify_inbound_request(body, headers={MCP_PROTOCOL_VERSION_HEADER: MODERN})
body = envelope(version=LATEST_HANDSHAKE_VERSION)
result = classify_inbound_request(body, headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION})
assert_rejected(result, HEADER_MISMATCH)

View file

@ -1755,6 +1755,39 @@ def test_plan_outbound_with_resumption_token_returns_client_metadata_and_suppres
assert _plan_outbound(None, {}) == _OutboundPlan(metadata=None, cancel_on_abandon=True)
@pytest.mark.anyio
async def test_send_raw_request_projects_opts_headers_onto_message_metadata():
"""`opts["headers"]` alone yields `ClientMessageMetadata(headers=...)` on the outbound `SessionMessage`
(SDK-defined: the headers sidecar is the path the session uses to reach the transport)."""
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
client: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(s2c_recv, c2s_send)
on_request, on_notify = echo_handlers(Recorder())
try:
async with anyio.create_task_group() as tg:
await tg.start(client.run, on_request, on_notify)
async def caller() -> None:
await client.send_raw_request("tools/list", None, {"headers": {"x-test": "v"}})
tg.start_soon(caller)
with anyio.fail_after(5):
outbound = await c2s_recv.receive()
assert isinstance(outbound, SessionMessage)
assert isinstance(outbound.message, JSONRPCRequest)
assert isinstance(outbound.metadata, ClientMessageMetadata)
assert outbound.metadata.headers == {"x-test": "v"}
assert outbound.metadata.resumption_token is None
await s2c_send.send(
SessionMessage(message=JSONRPCResponse(jsonrpc="2.0", id=outbound.message.id, result={}))
)
tg.cancel_scope.cancel()
finally:
for s in (c2s_send, c2s_recv, s2c_send, s2c_recv):
s.close()
@pytest.mark.anyio
async def test_response_with_string_id_correlates_to_int_keyed_pending_request():
"""A peer that echoes the request ID as a JSON string still resolves the waiter."""
@ -2324,7 +2357,7 @@ async def test_server_middleware_observes_cancelled_notification():
server = Server("test-server", on_call_tool=handle_call_tool)
server.middleware.append(observe)
async with Client(server) as client:
async with Client(server, mode="legacy") as client:
with anyio.fail_after(5):
async with anyio.create_task_group() as tg: # pragma: no branch

View file

@ -19,7 +19,7 @@ async def test_client_and_server_spans(capfire: CaptureLogfire):
"""Greet someone."""
return f"Hello, {name}!"
async with Client(server) as client:
async with Client(server, mode="legacy") as client:
result = await client.call_tool("greet", {"name": "World"})
assert isinstance(result.content[0], types.TextContent)

View file

@ -185,7 +185,7 @@ async def test_peer_notify_forwards_to_wrapped_outbound():
) -> dict[str, Any]:
raise NotImplementedError
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: Any = None) -> None:
sent.append((method, params))
await ClientPeer(_Out()).notify("n", {"x": 1})

View file

@ -3,8 +3,9 @@
import pytest
from mcp.shared.version import (
HANDSHAKE_PROTOCOL_VERSIONS,
KNOWN_PROTOCOL_VERSIONS,
SUPPORTED_PROTOCOL_VERSIONS,
MODERN_PROTOCOL_VERSIONS,
is_version_at_least,
)
@ -51,7 +52,8 @@ def test_is_version_at_least_matches_lexicographic_for_known_versions(version: s
def test_supported_versions_are_known() -> None:
"""Every negotiable revision must be in the ordering registry."""
assert set(SUPPORTED_PROTOCOL_VERSIONS) <= set(KNOWN_PROTOCOL_VERSIONS)
assert set(HANDSHAKE_PROTOCOL_VERSIONS) <= set(KNOWN_PROTOCOL_VERSIONS)
assert set(MODERN_PROTOCOL_VERSIONS) <= set(KNOWN_PROTOCOL_VERSIONS)
def test_known_versions_are_strictly_ordered() -> None: