Client-side 2026-07-28 support: .discover()/.adopt() + Client(mode=); request-metadata green (#2950)
This commit is contained in:
parent
ae13ede143
commit
f226d00d0a
5 changed files with 2533 additions and 993 deletions
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue