Client-side 2026-07-28 support: .discover()/.adopt() + Client(mode=); request-metadata green (#2950)
This commit is contained in:
parent
ae13ede143
commit
f226d00d0a
22 changed files with 2533 additions and 993 deletions
|
|
@ -31,8 +31,8 @@ from mcp.server.sse import SseServerTransport
|
|||
from mcp.server.streamable_http import EventStore
|
||||
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from mcp.shared.version import LATEST_HANDSHAKE_VERSION, MODERN_PROTOCOL_VERSIONS
|
||||
from mcp.types import (
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
ClientCapabilities,
|
||||
Implementation,
|
||||
InitializeRequestParams,
|
||||
|
|
@ -70,7 +70,7 @@ class Connect(Protocol):
|
|||
message_handler: MessageHandlerFnT | None = None,
|
||||
client_info: Implementation | None = None,
|
||||
elicitation_callback: ElicitationFnT | None = None,
|
||||
protocol_version: str = LATEST_PROTOCOL_VERSION,
|
||||
spec_version: str = LATEST_HANDSHAKE_VERSION,
|
||||
) -> AbstractAsyncContextManager[Client]: ...
|
||||
|
||||
|
||||
|
|
@ -85,11 +85,17 @@ async def connect_in_memory(
|
|||
message_handler: MessageHandlerFnT | None = None,
|
||||
client_info: Implementation | None = None,
|
||||
elicitation_callback: ElicitationFnT | None = None,
|
||||
protocol_version: str = LATEST_PROTOCOL_VERSION,
|
||||
spec_version: str = LATEST_HANDSHAKE_VERSION,
|
||||
) -> AsyncIterator[Client]:
|
||||
"""Yield a Client connected to the server over the in-memory transport."""
|
||||
"""Yield a Client connected to the server over the in-memory transport.
|
||||
|
||||
When `spec_version` is a modern (2026-07-28+) revision the Client is opened with
|
||||
`mode=<version>`, which drives the server through the DirectDispatcher peer-pair
|
||||
(per-request `serve_one`, no initialize handshake) instead of the legacy stream pair.
|
||||
"""
|
||||
async with Client(
|
||||
server,
|
||||
mode=spec_version if spec_version in MODERN_PROTOCOL_VERSIONS else "legacy",
|
||||
read_timeout_seconds=read_timeout_seconds,
|
||||
sampling_callback=sampling_callback,
|
||||
list_roots_callback=list_roots_callback,
|
||||
|
|
@ -97,7 +103,6 @@ async def connect_in_memory(
|
|||
message_handler=message_handler,
|
||||
client_info=client_info,
|
||||
elicitation_callback=elicitation_callback,
|
||||
protocol_version=protocol_version,
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
|
|
@ -117,7 +122,7 @@ async def connect_over_streamable_http(
|
|||
message_handler: MessageHandlerFnT | None = None,
|
||||
client_info: Implementation | None = None,
|
||||
elicitation_callback: ElicitationFnT | None = None,
|
||||
protocol_version: str = LATEST_PROTOCOL_VERSION,
|
||||
spec_version: str = LATEST_HANDSHAKE_VERSION,
|
||||
) -> AsyncIterator[Client]:
|
||||
"""Yield a Client connected to the server's streamable HTTP app, entirely in process.
|
||||
|
||||
|
|
@ -126,6 +131,10 @@ async def connect_over_streamable_http(
|
|||
transport-specific tests pass `json_response` to select the other server mode, and the
|
||||
resumability tests pass an `event_store` (with `retry_interval=0` so the client's
|
||||
reconnection wait is a no-op).
|
||||
|
||||
When `spec_version` is a modern (2026-07-28+) revision the Client is opened with
|
||||
`mode=<version>`, which adopts a synthesized DiscoverResult instead of running the legacy
|
||||
initialize handshake.
|
||||
"""
|
||||
app = server.streamable_http_app(
|
||||
stateless_http=stateless_http,
|
||||
|
|
@ -138,7 +147,8 @@ async def connect_over_streamable_http(
|
|||
server.session_manager.run(),
|
||||
httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client,
|
||||
Client(
|
||||
streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client, protocol_version=protocol_version),
|
||||
streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client),
|
||||
mode=spec_version if spec_version in MODERN_PROTOCOL_VERSIONS else "legacy",
|
||||
read_timeout_seconds=read_timeout_seconds,
|
||||
sampling_callback=sampling_callback,
|
||||
list_roots_callback=list_roots_callback,
|
||||
|
|
@ -146,7 +156,6 @@ async def connect_over_streamable_http(
|
|||
message_handler=message_handler,
|
||||
client_info=client_info,
|
||||
elicitation_callback=elicitation_callback,
|
||||
protocol_version=protocol_version,
|
||||
) as client,
|
||||
):
|
||||
yield client
|
||||
|
|
@ -229,6 +238,9 @@ async def client_via_http(
|
|||
transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client)
|
||||
async with Client(
|
||||
transport,
|
||||
# Callers assert the legacy HTTP wire shape (session-id header, standalone GET stream,
|
||||
# closing DELETE); the modern flow is sessionless and would silently change the subject.
|
||||
mode="legacy",
|
||||
logging_callback=logging_callback,
|
||||
message_handler=message_handler,
|
||||
elicitation_callback=elicitation_callback,
|
||||
|
|
@ -260,14 +272,14 @@ def base_headers(*, session_id: str | None = None) -> dict[str, str]:
|
|||
"""Standard request headers for raw-httpx streamable-HTTP tests.
|
||||
|
||||
Every well-formed request carries these (Accept covering both response representations,
|
||||
Content-Type for POST bodies, MCP-Protocol-Version at the latest revision, and the session
|
||||
Content-Type for POST bodies, MCP-Protocol-Version at the newest handshake revision, and the session
|
||||
ID once one exists), so a test that wants to assert a specific rejection only varies the one
|
||||
header under test.
|
||||
"""
|
||||
headers = {
|
||||
"accept": "application/json, text/event-stream",
|
||||
"content-type": "application/json",
|
||||
"mcp-protocol-version": LATEST_PROTOCOL_VERSION,
|
||||
"mcp-protocol-version": LATEST_HANDSHAKE_VERSION,
|
||||
}
|
||||
if session_id is not None:
|
||||
headers["mcp-session-id"] = session_id
|
||||
|
|
@ -277,7 +289,7 @@ def base_headers(*, session_id: str | None = None) -> dict[str, str]:
|
|||
def initialize_body(request_id: int = 1) -> dict[str, object]:
|
||||
"""A wire-level initialize JSON-RPC request body, exactly as an SDK client would send it."""
|
||||
params = InitializeRequestParams(
|
||||
protocol_version=LATEST_PROTOCOL_VERSION,
|
||||
protocol_version=LATEST_HANDSHAKE_VERSION,
|
||||
capabilities=ClientCapabilities(),
|
||||
client_info=Implementation(name="raw", version="0.0.0"),
|
||||
)
|
||||
|
|
@ -345,7 +357,7 @@ async def connect_over_sse(
|
|||
message_handler: MessageHandlerFnT | None = None,
|
||||
client_info: Implementation | None = None,
|
||||
elicitation_callback: ElicitationFnT | None = None,
|
||||
protocol_version: str = LATEST_PROTOCOL_VERSION,
|
||||
spec_version: str = LATEST_HANDSHAKE_VERSION,
|
||||
) -> AsyncIterator[Client]:
|
||||
"""Yield a Client connected to the server's legacy SSE transport, entirely in process."""
|
||||
app, _ = build_sse_app(server)
|
||||
|
|
@ -369,6 +381,8 @@ async def connect_over_sse(
|
|||
transport = sse_client(f"{BASE_URL}/sse", httpx_client_factory=httpx_client_factory)
|
||||
async with Client(
|
||||
transport,
|
||||
# SSE is a legacy-only transport; the modern path has no SSE story.
|
||||
mode="legacy",
|
||||
read_timeout_seconds=read_timeout_seconds,
|
||||
sampling_callback=sampling_callback,
|
||||
list_roots_callback=list_roots_callback,
|
||||
|
|
|
|||
|
|
@ -63,9 +63,7 @@ CONNECTABLE_TRANSPORTS: tuple[Transport, ...] = ("in-memory", "sse", "streamable
|
|||
|
||||
TRANSPORT_SPEC_VERSIONS: dict[Transport, tuple[SpecVersion, ...]] = {
|
||||
"sse": ("2025-11-25",),
|
||||
# Temporary lock: the in-memory transport has no modern entry point yet, so it cannot
|
||||
# negotiate the newer revision. Remove once an in-memory factory for the modern path lands.
|
||||
"in-memory": ("2025-11-25",),
|
||||
"in-memory": ("2025-11-25", "2026-07-28"),
|
||||
# At the newer revision the protocol-version header check runs before the stateless branch is
|
||||
# taken, so a stateless connection at that revision behaves identically to the stateful one.
|
||||
# Locked to avoid a redundant matrix column; revisit if the header/stateless ordering changes.
|
||||
|
|
@ -252,6 +250,7 @@ REQUIREMENTS: dict[str, Requirement] = {
|
|||
source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization",
|
||||
behavior="The client's name, version, and title are visible to server handlers after initialization.",
|
||||
removed_in="2026-07-28",
|
||||
superseded_by="lifecycle:envelope:stamped-on-every-request",
|
||||
note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.",
|
||||
arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),),
|
||||
),
|
||||
|
|
@ -262,6 +261,7 @@ REQUIREMENTS: dict[str, Requirement] = {
|
|||
"(sampling, elicitation, roots)."
|
||||
),
|
||||
removed_in="2026-07-28",
|
||||
superseded_by="lifecycle:envelope:stamped-on-every-request",
|
||||
note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.",
|
||||
arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),),
|
||||
),
|
||||
|
|
@ -395,6 +395,82 @@ REQUIREMENTS: dict[str, Requirement] = {
|
|||
"hosting:http:legacy-no-modern-vocabulary covers the same vocabulary set"
|
||||
),
|
||||
),
|
||||
"lifecycle:envelope:stamped-on-every-request": Requirement(
|
||||
source=f"{SPEC_2026_BASE_URL}/basic#_meta",
|
||||
behavior=(
|
||||
"Every client→server request on a modern-negotiated session carries "
|
||||
"_meta.{protocolVersion,clientInfo,clientCapabilities}; notifications do not."
|
||||
),
|
||||
added_in="2026-07-28",
|
||||
supersedes=("lifecycle:initialize:client-info", "lifecycle:initialize:client-capabilities"),
|
||||
),
|
||||
"lifecycle:envelope:header-matches-meta": Requirement(
|
||||
source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#headers",
|
||||
behavior="On HTTP, the MCP-Protocol-Version header on every POST matches _meta.protocolVersion in the body.",
|
||||
transports=("streamable-http", "streamable-http-stateless"),
|
||||
added_in="2026-07-28",
|
||||
note="HTTP-only: the header is a streamable-http transport concern; stdio and in-memory carry no headers.",
|
||||
),
|
||||
"lifecycle:discover:basic": Requirement(
|
||||
source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#discover",
|
||||
behavior=(
|
||||
"Calling discover() sends server/discover with no params and returns a typed DiscoverResult "
|
||||
"carrying protocolVersion, capabilities, serverInfo and the cache hint fields."
|
||||
),
|
||||
added_in="2026-07-28",
|
||||
),
|
||||
"lifecycle:discover:retry-on-32022": Requirement(
|
||||
source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#version-errors",
|
||||
behavior=(
|
||||
"When server/discover returns -32022 UnsupportedProtocolVersion, the client retries once with "
|
||||
"the intersection of error.data.supported and its own modern versions; an empty intersection raises."
|
||||
),
|
||||
added_in="2026-07-28",
|
||||
),
|
||||
"lifecycle:discover:fallback-method-not-found": Requirement(
|
||||
source=f"{SPEC_2026_BASE_URL}/basic/transports/stdio#backward-compatibility",
|
||||
behavior=(
|
||||
"When server/discover returns any JSON-RPC error or a bare HTTP 4xx, an auto-negotiating "
|
||||
"client falls back to the legacy initialize handshake and the connection succeeds at a "
|
||||
"handshake-era version (legacy servers reject the probe with various codes)."
|
||||
),
|
||||
added_in="2026-07-28",
|
||||
),
|
||||
"lifecycle:discover:network-error-raises": Requirement(
|
||||
source="sdk",
|
||||
behavior=(
|
||||
"A network/connection error during server/discover propagates to the caller without "
|
||||
"falling back to initialize; any rpc-error or 4xx falls back (legacy servers reject the "
|
||||
"probe with various codes). An outage is never an era verdict."
|
||||
),
|
||||
transports=("streamable-http", "streamable-http-stateless"),
|
||||
added_in="2026-07-28",
|
||||
note="HTTP-only: distinguishes transport-level failures from server-side rejection.",
|
||||
),
|
||||
"lifecycle:mode:legacy-never-probes": Requirement(
|
||||
source="sdk",
|
||||
behavior=(
|
||||
"A Client constructed with mode='legacy' sends initialize as its first request "
|
||||
"and never sends server/discover."
|
||||
),
|
||||
added_in="2026-07-28",
|
||||
),
|
||||
"lifecycle:mode:pin-never-handshakes": Requirement(
|
||||
source="sdk",
|
||||
behavior=(
|
||||
"A Client constructed with mode='2026-07-28' sends no initialize and no server/discover; its "
|
||||
"first wire request is the caller's first call, carrying the full _meta envelope."
|
||||
),
|
||||
added_in="2026-07-28",
|
||||
),
|
||||
"lifecycle:mode:prior-discover-zero-rtt": Requirement(
|
||||
source="sdk",
|
||||
behavior=(
|
||||
"A Client constructed with prior_discover=<DiscoverResult> sends no negotiation traffic; "
|
||||
"server_info and capabilities are populated from the prior result."
|
||||
),
|
||||
added_in="2026-07-28",
|
||||
),
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Protocol primitives: cancellation, timeout, progress, errors, _meta
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -581,7 +657,9 @@ REQUIREMENTS: dict[str, Requirement] = {
|
|||
"Progress notifications emitted by a handler during a request are delivered to the caller's "
|
||||
"progress callback, in order, with their progress, total, and message."
|
||||
),
|
||||
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
|
||||
known_failures=(
|
||||
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
|
||||
),
|
||||
),
|
||||
"protocol:progress:token-injected": Requirement(
|
||||
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
|
||||
|
|
@ -594,7 +672,14 @@ REQUIREMENTS: dict[str, Requirement] = {
|
|||
"protocol:progress:token-unique": Requirement(
|
||||
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
|
||||
behavior=("Concurrent in-flight requests that each supply a progress callback carry distinct progress tokens."),
|
||||
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
|
||||
note=(
|
||||
"Tested as the consequence: each callback receives only its own request's progress under "
|
||||
"interleaved emission. Token distinctness is the JSON-RPC mechanism for that; the in-process "
|
||||
"direct dispatcher carries the callback per-request without a wire-level token."
|
||||
),
|
||||
known_failures=(
|
||||
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
|
||||
),
|
||||
),
|
||||
"protocol:progress:monotonic": Requirement(
|
||||
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
|
||||
|
|
@ -607,7 +692,9 @@ REQUIREMENTS: dict[str, Requirement] = {
|
|||
"handler that emits non-increasing values has them forwarded to the callback unchanged."
|
||||
),
|
||||
),
|
||||
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
|
||||
known_failures=(
|
||||
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
|
||||
),
|
||||
),
|
||||
"protocol:progress:stops-after-completion": Requirement(
|
||||
source=f"{SPEC_BASE_URL}/basic/utilities/progress#behavior-requirements",
|
||||
|
|
@ -745,7 +832,9 @@ REQUIREMENTS: dict[str, Requirement] = {
|
|||
"Log notifications emitted by a tool handler during execution reach the client's logging "
|
||||
"callback before the tool result returns."
|
||||
),
|
||||
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
|
||||
known_failures=(
|
||||
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
|
||||
),
|
||||
),
|
||||
"tools:call:progress": Requirement(
|
||||
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
|
||||
|
|
@ -753,7 +842,9 @@ REQUIREMENTS: dict[str, Requirement] = {
|
|||
"Progress notifications emitted by a tool handler reach the caller's progress callback before "
|
||||
"the tool result returns."
|
||||
),
|
||||
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
|
||||
known_failures=(
|
||||
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
|
||||
),
|
||||
),
|
||||
"tools:call:sampling-roundtrip": Requirement(
|
||||
source=f"{SPEC_BASE_URL}/client/sampling#creating-messages",
|
||||
|
|
@ -974,14 +1065,18 @@ REQUIREMENTS: dict[str, Requirement] = {
|
|||
"The Context logging helpers (debug/info/warning/error) send log message notifications at the "
|
||||
"corresponding severity."
|
||||
),
|
||||
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
|
||||
known_failures=(
|
||||
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
|
||||
),
|
||||
),
|
||||
"mcpserver:context:progress": Requirement(
|
||||
source="sdk",
|
||||
behavior=(
|
||||
"Context.report_progress sends a progress notification against the requesting client's progress token."
|
||||
),
|
||||
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
|
||||
known_failures=(
|
||||
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
|
||||
),
|
||||
),
|
||||
"mcpserver:context:elicit": Requirement(
|
||||
source="sdk",
|
||||
|
|
@ -1339,7 +1434,9 @@ REQUIREMENTS: dict[str, Requirement] = {
|
|||
"logging:message:all-levels": Requirement(
|
||||
source=f"{SPEC_BASE_URL}/server/utilities/logging#log-levels",
|
||||
behavior="All eight RFC 5424 severity levels are deliverable as log message notifications.",
|
||||
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
|
||||
known_failures=(
|
||||
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
|
||||
),
|
||||
),
|
||||
"logging:message:fields": Requirement(
|
||||
source=f"{SPEC_BASE_URL}/server/utilities/logging#log-message-notifications",
|
||||
|
|
@ -1347,7 +1444,9 @@ REQUIREMENTS: dict[str, Requirement] = {
|
|||
"A log message sent by a server handler is delivered to the client's logging callback with its "
|
||||
"severity level, logger name, and data."
|
||||
),
|
||||
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
|
||||
known_failures=(
|
||||
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
|
||||
),
|
||||
),
|
||||
"logging:message:filtered": Requirement(
|
||||
source=f"{SPEC_BASE_URL}/server/utilities/logging#setting-log-level",
|
||||
|
|
@ -1970,6 +2069,7 @@ REQUIREMENTS: dict[str, Requirement] = {
|
|||
known_failures=(
|
||||
KnownFailure(
|
||||
spec_version="2026-07-28",
|
||||
transport="streamable-http",
|
||||
note=(
|
||||
"List-mutation assertions hold; only the sentinel ctx.info() never reaches the client. "
|
||||
+ _MODERN_NOTIFY_DROP
|
||||
|
|
|
|||
|
|
@ -470,6 +470,7 @@ async def connect_with_oauth(
|
|||
)
|
||||
headless.bind(http_client)
|
||||
client = await stack.enter_async_context(
|
||||
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client))
|
||||
# The auth flow tests snapshot the legacy initialize-handshake HTTP shape.
|
||||
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client), mode="legacy")
|
||||
)
|
||||
yield client, headless
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ async def test_an_unauthenticated_request_is_challenged_then_the_full_oauth_flow
|
|||
# The first PRM discovery GET carries the protocol-version header (an SDK behaviour, not a
|
||||
# spec requirement on discovery requests).
|
||||
prm_get = next(r for r in requests if r.url.path == "/.well-known/oauth-protected-resource/mcp")
|
||||
assert prm_get.headers.get("mcp-protocol-version") == snapshot("2025-11-25")
|
||||
assert prm_get.headers.get("mcp-protocol-version") == snapshot("2026-07-28")
|
||||
|
||||
authorize = parse_qs(urlsplit(headless.authorize_url).query)
|
||||
assert authorize["response_type"] == ["code"]
|
||||
|
|
|
|||
|
|
@ -45,4 +45,4 @@ def connect(request: pytest.FixtureRequest) -> Connect:
|
|||
transport, spec_version = request.param
|
||||
assert isinstance(transport, str)
|
||||
assert isinstance(spec_version, str)
|
||||
return partial(_FACTORIES[transport], protocol_version=spec_version)
|
||||
return partial(_FACTORIES[transport], spec_version=spec_version)
|
||||
|
|
|
|||
369
tests/interaction/lowlevel/test_client_connect.py
Normal file
369
tests/interaction/lowlevel/test_client_connect.py
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
"""Client connect-time negotiation: mode selection, server/discover, and the per-request envelope.
|
||||
|
||||
These tests pin what `Client(..., mode=...)` puts on the wire BEFORE the caller's first call --
|
||||
the legacy initialize handshake, the modern `server/discover` probe, or nothing at all -- and
|
||||
that a modern-negotiated session stamps the three-key `io.modelcontextprotocol/*` `_meta`
|
||||
envelope on every subsequent request. Each test drives the highest public surface (`Client`)
|
||||
and observes traffic at a recording seam: `RecordingTransport` for the legacy stream pair, and
|
||||
`mounted_app`'s httpx event hook for the in-process streamable-HTTP transport.
|
||||
|
||||
The fallback test alone hand-plays the server's side of the wire, because no real `Server`
|
||||
answers `server/discover` with -32601.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from mcp import MCPError, types
|
||||
from mcp.client._memory import InMemoryTransport
|
||||
from mcp.client._transport import TransportStreams
|
||||
from mcp.client.client import Client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
|
||||
from mcp.shared.message import SessionMessage
|
||||
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,
|
||||
INVALID_REQUEST,
|
||||
METHOD_NOT_FOUND,
|
||||
PROTOCOL_VERSION_META_KEY,
|
||||
UNSUPPORTED_PROTOCOL_VERSION,
|
||||
DiscoverResult,
|
||||
Implementation,
|
||||
InitializeResult,
|
||||
JSONRPCError,
|
||||
JSONRPCNotification,
|
||||
JSONRPCRequest,
|
||||
JSONRPCResponse,
|
||||
ServerCapabilities,
|
||||
ToolsCapability,
|
||||
)
|
||||
from tests.interaction._connect import BASE_URL, Connect, mounted_app
|
||||
from tests.interaction._helpers import RecordingTransport
|
||||
from tests.interaction._requirements import requirement
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
def _tools_server(name: str = "negotiator") -> Server:
|
||||
"""A low-level server with one list-tools handler, so a feature request has something to reach."""
|
||||
|
||||
async def list_tools(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
return types.ListToolsResult(tools=[types.Tool(name="noop", input_schema={"type": "object"})])
|
||||
|
||||
return Server(name, on_list_tools=list_tools)
|
||||
|
||||
|
||||
def _request_recorder() -> tuple[list[httpx.Request], Callable[[httpx.Request], Awaitable[None]]]:
|
||||
"""Return a list and an `on_request` hook that appends each outgoing httpx request to it."""
|
||||
captured: list[httpx.Request] = []
|
||||
|
||||
async def on_request(request: httpx.Request) -> None:
|
||||
captured.append(request)
|
||||
|
||||
return captured, on_request
|
||||
|
||||
|
||||
@requirement("lifecycle:mode:legacy-never-probes")
|
||||
async def test_legacy_mode_sends_initialize_and_never_probes_discover() -> None:
|
||||
"""`Client(server, mode='legacy')` opens with `initialize` and never sends `server/discover`.
|
||||
|
||||
Requirement `lifecycle:mode:legacy-never-probes` (sdk-defined): ``mode='legacy'`` must remain
|
||||
byte-identical to the pre-2026 client so a 2025-era server never observes modern vocabulary.
|
||||
"""
|
||||
recording = RecordingTransport(InMemoryTransport(_tools_server()))
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(recording, mode="legacy") as client:
|
||||
await client.list_tools()
|
||||
|
||||
sent = [m.message for m in recording.sent]
|
||||
methods = [m.method for m in sent if isinstance(m, JSONRPCRequest | JSONRPCNotification)]
|
||||
assert methods[0] == "initialize"
|
||||
assert "server/discover" not in methods
|
||||
assert "notifications/initialized" in methods
|
||||
|
||||
|
||||
@requirement("lifecycle:mode:pin-never-handshakes")
|
||||
async def test_pinned_mode_sends_no_connect_time_traffic() -> None:
|
||||
"""`Client(..., mode='2026-07-28')` sends nothing on entry; the caller's first call is the first wire request.
|
||||
|
||||
Requirement `lifecycle:mode:pin-never-handshakes` (sdk-defined): a version pin adopts a
|
||||
synthesized DiscoverResult locally, so no `initialize` and no `server/discover` ever cross
|
||||
the wire. Asserted at the in-process streamable-HTTP seam via the httpx event hook.
|
||||
"""
|
||||
requests, on_request = _request_recorder()
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with (
|
||||
mounted_app(_tools_server(), on_request=on_request) as (http, _),
|
||||
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode=LATEST_MODERN_VERSION) as client,
|
||||
):
|
||||
assert requests == [] # entering the Client produced zero HTTP traffic
|
||||
result = await client.list_tools()
|
||||
|
||||
bodies = [json.loads(r.content) for r in requests]
|
||||
assert [b["method"] for b in bodies] == ["tools/list"]
|
||||
assert PROTOCOL_VERSION_META_KEY in bodies[0]["params"]["_meta"]
|
||||
assert [t.name for t in result.tools] == ["noop"]
|
||||
|
||||
|
||||
@requirement("lifecycle:mode:prior-discover-zero-rtt")
|
||||
async def test_prior_discover_populates_state_with_zero_connect_time_traffic() -> None:
|
||||
"""`Client(..., mode=<pin>, prior_discover=...)` sends nothing on entry and exposes the prior server_info.
|
||||
|
||||
Requirement `lifecycle:mode:prior-discover-zero-rtt` (sdk-defined): a previously-obtained
|
||||
DiscoverResult is installed via `adopt()` so server_info and capabilities are available
|
||||
immediately with zero round trips.
|
||||
"""
|
||||
prior = DiscoverResult(
|
||||
supported_versions=[LATEST_MODERN_VERSION],
|
||||
capabilities=ServerCapabilities(tools=ToolsCapability(list_changed=False)),
|
||||
server_info=Implementation(name="cached-server", version="9.9.9"),
|
||||
)
|
||||
requests, on_request = _request_recorder()
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with (
|
||||
mounted_app(_tools_server(), on_request=on_request) as (http, _),
|
||||
Client(
|
||||
streamable_http_client(f"{BASE_URL}/mcp", http_client=http),
|
||||
mode=LATEST_MODERN_VERSION,
|
||||
prior_discover=prior,
|
||||
) as client,
|
||||
):
|
||||
assert requests == []
|
||||
assert client.server_info == Implementation(name="cached-server", version="9.9.9")
|
||||
assert client.server_capabilities.tools == ToolsCapability(list_changed=False)
|
||||
await client.list_tools()
|
||||
|
||||
assert [json.loads(r.content)["method"] for r in requests] == ["tools/list"]
|
||||
|
||||
|
||||
@requirement("lifecycle:discover:basic")
|
||||
async def test_auto_mode_probes_server_discover_and_adopts_the_result() -> None:
|
||||
"""`Client(..., mode='auto')` sends `server/discover` first and adopts the returned version and server_info.
|
||||
|
||||
Requirement `lifecycle:discover:basic` (spec basic/lifecycle#discover): the probe is a
|
||||
single `server/discover` request whose result carries supported versions, capabilities,
|
||||
server_info and the cache-hint fields, after which the session is modern-negotiated.
|
||||
"""
|
||||
requests, on_request = _request_recorder()
|
||||
server = _tools_server("discoverable")
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with (
|
||||
mounted_app(server, on_request=on_request) as (http, _),
|
||||
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto") as client,
|
||||
):
|
||||
assert client.protocol_version == LATEST_MODERN_VERSION
|
||||
assert client.server_info.name == "discoverable"
|
||||
await client.list_tools()
|
||||
|
||||
bodies = [json.loads(r.content) for r in requests]
|
||||
assert bodies[0]["method"] == "server/discover"
|
||||
assert "initialize" not in [b["method"] for b in bodies]
|
||||
|
||||
|
||||
@requirement("lifecycle:discover:retry-on-32022")
|
||||
async def test_auto_mode_retries_discover_once_on_unsupported_protocol_version() -> None:
|
||||
"""A -32022 from `server/discover` triggers exactly one retry at the highest mutual modern version.
|
||||
|
||||
Requirement `lifecycle:discover:retry-on-32022` (spec basic/lifecycle#version-errors): the
|
||||
client intersects `error.data.supported` with its own modern versions and re-probes once;
|
||||
the second success is adopted. The server's `server/discover` handler is overridden to fail
|
||||
the first call and succeed on the second.
|
||||
"""
|
||||
calls: list[str | None] = []
|
||||
|
||||
async def discover(ctx: ServerRequestContext, params: types.RequestParams | None) -> DiscoverResult:
|
||||
proposed = ctx.meta.get(PROTOCOL_VERSION_META_KEY) if ctx.meta else None
|
||||
calls.append(proposed)
|
||||
if len(calls) == 1:
|
||||
raise MCPError(
|
||||
code=UNSUPPORTED_PROTOCOL_VERSION,
|
||||
message="unsupported protocol version",
|
||||
data={"supported": list(MODERN_PROTOCOL_VERSIONS), "requested": proposed},
|
||||
)
|
||||
return DiscoverResult(
|
||||
supported_versions=list(MODERN_PROTOCOL_VERSIONS),
|
||||
capabilities=ServerCapabilities(),
|
||||
server_info=Implementation(name="picky", version="1.0.0"),
|
||||
)
|
||||
|
||||
server = _tools_server("picky")
|
||||
server.add_request_handler("server/discover", types.RequestParams, discover)
|
||||
requests, on_request = _request_recorder()
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with (
|
||||
mounted_app(server, on_request=on_request) as (http, _),
|
||||
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto") as client,
|
||||
):
|
||||
assert client.protocol_version == LATEST_MODERN_VERSION
|
||||
|
||||
assert calls == [LATEST_MODERN_VERSION, LATEST_MODERN_VERSION]
|
||||
assert [json.loads(r.content)["method"] for r in requests][:2] == ["server/discover", "server/discover"]
|
||||
|
||||
|
||||
@requirement("lifecycle:discover:network-error-raises")
|
||||
async def test_auto_mode_propagates_a_network_error_from_discover_without_initializing() -> None:
|
||||
"""A network/connection error during `server/discover` propagates to the caller without falling back.
|
||||
|
||||
Requirement `lifecycle:discover:network-error-raises` (sdk-defined): under the denylist policy
|
||||
every server-sent rpc-error and every transport-layer 4xx falls back to `initialize()`; the
|
||||
only probe failures that reach the caller are real outages — network errors, anyio resource
|
||||
errors, and the disjoint-modern -32022 case. Exercised here as an `httpx.ConnectError` from
|
||||
the underlying transport, which the policy must not classify as an era verdict. The error
|
||||
reaches the test wrapped in the streamable-http transport's task-group teardown, so
|
||||
`pytest.RaisesGroup` flattens before matching. The probe POST is recorded before the
|
||||
transport raises, so the `initialize` fallback observably did not happen.
|
||||
"""
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
raise httpx.ConnectError("connection refused")
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||
with pytest.RaisesGroup(httpx.ConnectError, flatten_subgroups=True): # pragma: no branch
|
||||
async with Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto"):
|
||||
raise NotImplementedError("entering the Client should have raised") # pragma: no cover
|
||||
|
||||
assert [json.loads(r.content)["method"] for r in requests] == ["server/discover"]
|
||||
|
||||
|
||||
@requirement("lifecycle:discover:fallback-method-not-found")
|
||||
@pytest.mark.parametrize(
|
||||
("probe_code", "probe_message"),
|
||||
[
|
||||
(METHOD_NOT_FOUND, "Method not found"),
|
||||
(INVALID_REQUEST, "Bad Request: Missing session ID"),
|
||||
],
|
||||
ids=["method-not-found", "invalid-request"],
|
||||
)
|
||||
async def test_auto_mode_falls_back_to_initialize_on_a_legacy_probe_rejection(
|
||||
probe_code: int, probe_message: str
|
||||
) -> None:
|
||||
"""A legacy server's rejection of `server/discover` makes an auto-negotiating client fall back to `initialize`.
|
||||
|
||||
Requirement `lifecycle:discover:fallback-method-not-found` (spec stdio#backward-compatibility):
|
||||
a legacy-era server that does not implement `server/discover` is connected to via the
|
||||
handshake, and the session lands at a handshake-era protocol version. The probe rejection
|
||||
arrives as METHOD_NOT_FOUND from a server that routes the unknown method, or as
|
||||
INVALID_REQUEST from a deployed v1.x stateful streamable-HTTP server that rejects the
|
||||
session-id-less probe before dispatch. A real `Server` always implements `server/discover`,
|
||||
so this test plays the server's side of the wire by hand. Reserve this pattern for behaviour
|
||||
no real server can be made to produce.
|
||||
"""
|
||||
methods_seen: list[str] = []
|
||||
|
||||
async def scripted_server(streams: MessageStream) -> None:
|
||||
server_read, server_write = streams
|
||||
async for message in server_read:
|
||||
assert isinstance(message, SessionMessage)
|
||||
frame = message.message
|
||||
assert isinstance(frame, JSONRPCRequest | JSONRPCNotification)
|
||||
methods_seen.append(frame.method)
|
||||
if isinstance(frame, JSONRPCRequest) and frame.method == "server/discover":
|
||||
error = types.ErrorData(code=probe_code, message=probe_message)
|
||||
await server_write.send(SessionMessage(JSONRPCError(jsonrpc="2.0", id=frame.id, error=error)))
|
||||
elif isinstance(frame, JSONRPCRequest) and frame.method == "initialize":
|
||||
result = InitializeResult(
|
||||
protocol_version=LATEST_HANDSHAKE_VERSION,
|
||||
capabilities=ServerCapabilities(),
|
||||
server_info=Implementation(name="legacy-only", version="0.0.1"),
|
||||
)
|
||||
await server_write.send(
|
||||
SessionMessage(
|
||||
JSONRPCResponse(
|
||||
jsonrpc="2.0",
|
||||
id=frame.id,
|
||||
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
|
||||
)
|
||||
)
|
||||
)
|
||||
# notifications/initialized (and anything else) is observed and ignored.
|
||||
|
||||
@asynccontextmanager
|
||||
async def scripted_transport() -> AsyncIterator[TransportStreams]:
|
||||
async with (
|
||||
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
|
||||
anyio.create_task_group() as tg,
|
||||
):
|
||||
tg.start_soon(scripted_server, server_streams)
|
||||
yield client_read, client_write
|
||||
tg.cancel_scope.cancel()
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(scripted_transport(), mode="auto") as client:
|
||||
assert client.protocol_version == LATEST_HANDSHAKE_VERSION
|
||||
assert client.server_info.name == "legacy-only"
|
||||
|
||||
assert methods_seen == ["server/discover", "initialize", "notifications/initialized"]
|
||||
|
||||
|
||||
@requirement("lifecycle:envelope:stamped-on-every-request")
|
||||
async def test_every_request_on_a_modern_session_carries_the_three_key_meta_envelope(connect: Connect) -> None:
|
||||
"""Each modern-session request's `params._meta` carries protocolVersion, clientInfo and clientCapabilities.
|
||||
|
||||
Requirement `lifecycle:envelope:stamped-on-every-request` (spec basic#_meta): the per-request
|
||||
envelope replaces the initialize handshake's once-per-session exchange. Asserted server-side
|
||||
by capturing `ctx.meta` inside the handler.
|
||||
"""
|
||||
observed: list[dict[str, object]] = []
|
||||
|
||||
async def list_tools(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
assert ctx.meta is not None
|
||||
observed.append(dict(ctx.meta))
|
||||
return types.ListToolsResult(tools=[])
|
||||
|
||||
server = Server("stamped", on_list_tools=list_tools)
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with connect(server, client_info=Implementation(name="enveloper", version="1.2.3")) as client:
|
||||
await client.list_tools()
|
||||
await client.list_tools()
|
||||
|
||||
assert len(observed) == 2
|
||||
for meta in observed:
|
||||
assert meta[PROTOCOL_VERSION_META_KEY] == LATEST_MODERN_VERSION
|
||||
assert meta[CLIENT_INFO_META_KEY] == {"name": "enveloper", "version": "1.2.3"}
|
||||
assert CLIENT_CAPABILITIES_META_KEY in meta
|
||||
|
||||
|
||||
@requirement("lifecycle:envelope:header-matches-meta")
|
||||
async def test_http_protocol_version_header_matches_meta_protocol_version_on_every_post() -> None:
|
||||
"""On streamable-HTTP, the `MCP-Protocol-Version` header on each POST equals `_meta.protocolVersion` in its body.
|
||||
|
||||
Requirement `lifecycle:envelope:header-matches-meta` (spec streamable-http#headers): the
|
||||
body-derived header and the envelope's protocol version are kept in lockstep so the server's
|
||||
header-based routing and body-based validation never disagree.
|
||||
"""
|
||||
requests, on_request = _request_recorder()
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with (
|
||||
mounted_app(_tools_server(), on_request=on_request) as (http, _),
|
||||
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode=LATEST_MODERN_VERSION) as client,
|
||||
):
|
||||
await client.list_tools()
|
||||
await client.list_tools()
|
||||
|
||||
assert requests, "no HTTP traffic recorded"
|
||||
for request in requests:
|
||||
body = json.loads(request.content)
|
||||
assert request.headers["mcp-protocol-version"] == body["params"]["_meta"][PROTOCOL_VERSION_META_KEY]
|
||||
assert request.headers["mcp-protocol-version"] == LATEST_MODERN_VERSION
|
||||
|
|
@ -123,7 +123,7 @@ async def test_complete_without_handler_is_method_not_found(connect: Connect) ->
|
|||
server = Server("incomplete")
|
||||
|
||||
async with connect(server) as client:
|
||||
assert client.initialize_result.capabilities.completions is None
|
||||
assert client.server_capabilities.completions is None
|
||||
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.complete(PromptReference(name="anything"), argument={"name": "topic", "value": ""})
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ async def test_initialize_returns_server_info(connect: Connect) -> None:
|
|||
)
|
||||
|
||||
async with connect(server) as client:
|
||||
server_info = client.initialize_result.server_info
|
||||
server_info = client.server_info
|
||||
|
||||
assert server_info == snapshot(
|
||||
Implementation(
|
||||
|
|
@ -78,10 +78,10 @@ async def test_initialize_returns_server_info(connect: Connect) -> None:
|
|||
async def test_initialize_returns_instructions(connect: Connect) -> None:
|
||||
"""Instructions are returned when the server declares them and omitted when it does not."""
|
||||
async with connect(Server("guided", instructions="Call the add tool.")) as client:
|
||||
assert client.initialize_result.instructions == snapshot("Call the add tool.")
|
||||
assert client.instructions == snapshot("Call the add tool.")
|
||||
|
||||
async with connect(Server("unguided")) as client:
|
||||
assert client.initialize_result.instructions is None
|
||||
assert client.instructions is None
|
||||
|
||||
|
||||
@requirement("lifecycle:initialize:capabilities:from-handlers")
|
||||
|
|
@ -137,7 +137,7 @@ async def test_initialize_capabilities_reflect_registered_handlers(connect: Conn
|
|||
)
|
||||
|
||||
async with connect(server) as client:
|
||||
capabilities = client.initialize_result.capabilities
|
||||
capabilities = client.server_capabilities
|
||||
|
||||
assert capabilities == snapshot(
|
||||
ServerCapabilities(
|
||||
|
|
@ -155,7 +155,7 @@ async def test_initialize_capabilities_reflect_registered_handlers(connect: Conn
|
|||
async def test_initialize_minimal_server_advertises_no_capabilities(connect: Connect) -> None:
|
||||
"""A server with no feature handlers advertises no feature capabilities."""
|
||||
async with connect(Server("bare")) as client:
|
||||
capabilities = client.initialize_result.capabilities
|
||||
capabilities = client.server_capabilities
|
||||
|
||||
assert capabilities == snapshot(ServerCapabilities(experimental={}))
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ async def test_client_ping_returns_empty_result(connect: Connect) -> None:
|
|||
server = Server("silent")
|
||||
|
||||
async with connect(server) as client:
|
||||
result = await client.send_ping()
|
||||
result = await client.send_ping() # pyright: ignore[reportDeprecated]
|
||||
|
||||
assert result == snapshot(EmptyResult())
|
||||
|
||||
|
|
|
|||
|
|
@ -41,18 +41,9 @@ async def test_progress_during_tool_call_reaches_callback_in_order(connect: Conn
|
|||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name == "download"
|
||||
assert ctx.meta is not None
|
||||
token = ctx.meta.get("progress_token")
|
||||
assert token is not None
|
||||
await ctx.session.send_progress_notification(
|
||||
token, 1.0, total=3.0, message="first chunk", related_request_id=str(ctx.request_id)
|
||||
)
|
||||
await ctx.session.send_progress_notification(
|
||||
token, 2.0, total=3.0, message="second chunk", related_request_id=str(ctx.request_id)
|
||||
)
|
||||
await ctx.session.send_progress_notification(
|
||||
token, 3.0, total=3.0, message="done", related_request_id=str(ctx.request_id)
|
||||
)
|
||||
await ctx.session.report_progress(1.0, total=3.0, message="first chunk")
|
||||
await ctx.session.report_progress(2.0, total=3.0, message="second chunk")
|
||||
await ctx.session.report_progress(3.0, total=3.0, message="done")
|
||||
return CallToolResult(content=[TextContent(text="downloaded")])
|
||||
|
||||
server = Server("downloader", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
|
|
@ -130,7 +121,7 @@ async def test_client_progress_notification_reaches_server_handler(connect: Conn
|
|||
server = Server("observer", on_progress=on_progress)
|
||||
|
||||
async with connect(server) as client:
|
||||
await client.send_progress_notification("upload-1", 0.5, total=1.0, message="halfway")
|
||||
await client.send_progress_notification("upload-1", 0.5, total=1.0, message="halfway") # pyright: ignore[reportDeprecated]
|
||||
with anyio.fail_after(5):
|
||||
await delivered.wait()
|
||||
|
||||
|
|
@ -147,12 +138,11 @@ async def test_concurrent_requests_carry_distinct_progress_tokens(connect: Conne
|
|||
token would be live at a time and the demultiplexing would never be exercised. The handlers each
|
||||
block until both have started and then hand control back and forth so the four progress
|
||||
notifications are emitted in strict a, b, a, b order on the wire. The two handlers send different
|
||||
progress values so a stream swap (token A delivered to callback B and vice versa) would fail: each
|
||||
callback receiving exactly its own values proves notifications are routed by token, not by arrival
|
||||
order or by chance.
|
||||
progress values so a stream swap (request A's progress delivered to callback B and vice versa)
|
||||
would fail: each callback receiving exactly its own values proves notifications are routed
|
||||
per-request, not by arrival order or by chance.
|
||||
"""
|
||||
progress_values = {"a": (1.0, 2.0), "b": (10.0, 20.0)}
|
||||
tokens: dict[str, ProgressToken] = {}
|
||||
entered = {"a": anyio.Event(), "b": anyio.Event()}
|
||||
# turns[n] is set to release the nth emission; each emission releases the next.
|
||||
turns = [anyio.Event() for _ in range(4)]
|
||||
|
|
@ -165,23 +155,15 @@ async def test_concurrent_requests_carry_distinct_progress_tokens(connect: Conne
|
|||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name == "report"
|
||||
assert params.arguments is not None
|
||||
assert ctx.meta is not None
|
||||
token = ctx.meta.get("progress_token")
|
||||
assert token is not None
|
||||
label = params.arguments["label"]
|
||||
tokens[label] = token
|
||||
entered[label].set()
|
||||
# The two handlers interleave by waiting on alternating turns: a takes 0 and 2, b takes 1 and 3.
|
||||
first, second = (0, 2) if label == "a" else (1, 3)
|
||||
await turns[first].wait()
|
||||
await ctx.session.send_progress_notification(
|
||||
token, progress_values[label][0], related_request_id=str(ctx.request_id)
|
||||
)
|
||||
await ctx.session.report_progress(progress_values[label][0])
|
||||
turns[first + 1].set()
|
||||
await turns[second].wait()
|
||||
await ctx.session.send_progress_notification(
|
||||
token, progress_values[label][1], related_request_id=str(ctx.request_id)
|
||||
)
|
||||
await ctx.session.report_progress(progress_values[label][1])
|
||||
if second + 1 < len(turns):
|
||||
turns[second + 1].set()
|
||||
return CallToolResult(content=[TextContent(text="done")])
|
||||
|
|
@ -210,7 +192,6 @@ async def test_concurrent_requests_carry_distinct_progress_tokens(connect: Conne
|
|||
await entered["b"].wait()
|
||||
turns[0].set()
|
||||
|
||||
assert tokens["a"] != tokens["b"]
|
||||
assert received_a == [1.0, 2.0]
|
||||
assert received_b == [10.0, 20.0]
|
||||
|
||||
|
|
@ -285,12 +266,9 @@ async def test_non_increasing_progress_values_are_forwarded_unchanged(connect: C
|
|||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name == "zigzag"
|
||||
assert ctx.meta is not None
|
||||
token = ctx.meta.get("progress_token")
|
||||
assert token is not None
|
||||
await ctx.session.send_progress_notification(token, 0.5, related_request_id=str(ctx.request_id))
|
||||
await ctx.session.send_progress_notification(token, 0.3, related_request_id=str(ctx.request_id))
|
||||
await ctx.session.send_progress_notification(token, 0.9, related_request_id=str(ctx.request_id))
|
||||
await ctx.session.report_progress(0.5)
|
||||
await ctx.session.report_progress(0.3)
|
||||
await ctx.session.report_progress(0.9)
|
||||
return CallToolResult(content=[TextContent(text="done")])
|
||||
|
||||
server = Server("zigzagger", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ async def test_request_timeout_fails_the_pending_call() -> None:
|
|||
|
||||
server = Server("blocker", on_call_tool=call_tool)
|
||||
|
||||
async with Client(server) as client:
|
||||
async with Client(server, mode="legacy") as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("block", {}, read_timeout_seconds=0.000001)
|
||||
|
||||
|
|
@ -106,7 +106,7 @@ async def test_server_request_timeout_sends_cancellation_to_the_client() -> None
|
|||
await release.wait()
|
||||
return types.CreateMessageResult(role="assistant", content=TextContent(text="too late"), model="test-model")
|
||||
|
||||
async with Client(recording, sampling_callback=sampling_callback) as client:
|
||||
async with Client(recording, mode="legacy", sampling_callback=sampling_callback) as client:
|
||||
result = await client.call_tool("impatient", {})
|
||||
|
||||
assert result == snapshot(CallToolResult(content=[TextContent(text="gave up")]))
|
||||
|
|
@ -147,7 +147,7 @@ async def test_session_serves_requests_after_timeout() -> None:
|
|||
|
||||
server = Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
|
||||
async with Client(server) as client:
|
||||
async with Client(server, mode="legacy") as client:
|
||||
with pytest.raises(MCPError):
|
||||
await client.call_tool("block", {}, read_timeout_seconds=0.000001)
|
||||
|
||||
|
|
@ -179,7 +179,7 @@ async def test_session_level_timeout_applies_to_every_request() -> None:
|
|||
|
||||
server = Server("blocker", on_call_tool=call_tool)
|
||||
|
||||
async with Client(server, read_timeout_seconds=0.05) as client:
|
||||
async with Client(server, mode="legacy", read_timeout_seconds=0.05) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("block", {})
|
||||
|
||||
|
|
|
|||
|
|
@ -65,11 +65,11 @@ async def test_request_ids_are_unique_and_never_null() -> None:
|
|||
"""
|
||||
recording = RecordingTransport(InMemoryTransport(_echo_server()))
|
||||
|
||||
async with Client(recording) as client:
|
||||
async with Client(recording, mode="legacy") as client:
|
||||
await client.list_tools()
|
||||
await client.call_tool("echo", {})
|
||||
await client.call_tool("echo", {})
|
||||
await client.send_ping()
|
||||
await client.send_ping() # pyright: ignore[reportDeprecated]
|
||||
|
||||
sent = [message.message for message in recording.sent]
|
||||
request_ids = [message.id for message in sent if isinstance(message, JSONRPCRequest)]
|
||||
|
|
@ -95,9 +95,9 @@ async def test_notifications_are_never_answered() -> None:
|
|||
|
||||
recording = RecordingTransport(InMemoryTransport(_echo_server()))
|
||||
|
||||
async with Client(recording, list_roots_callback=list_roots) as client:
|
||||
async with Client(recording, mode="legacy", list_roots_callback=list_roots) as client:
|
||||
await client.send_roots_list_changed() # pyright: ignore[reportDeprecated]
|
||||
await client.send_ping()
|
||||
await client.send_ping() # pyright: ignore[reportDeprecated]
|
||||
|
||||
sent = [message.message for message in recording.sent]
|
||||
sent_request_ids = [message.id for message in sent if isinstance(message, JSONRPCRequest)]
|
||||
|
|
@ -134,7 +134,7 @@ async def test_exactly_one_initialized_notification_is_sent_after_the_handshake(
|
|||
"""
|
||||
recording = RecordingTransport(InMemoryTransport(_echo_server()))
|
||||
|
||||
async with Client(recording) as client:
|
||||
async with Client(recording, mode="legacy") as client:
|
||||
await client.list_tools()
|
||||
|
||||
sent_methods = [
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ async def test_completion_capability_is_advertised_only_when_a_handler_is_regist
|
|||
raise NotImplementedError
|
||||
|
||||
async with connect(with_handler) as client:
|
||||
assert client.initialize_result.capabilities.completions == CompletionsCapability()
|
||||
assert client.server_capabilities.completions == CompletionsCapability()
|
||||
|
||||
async with connect(MCPServer("plain")) as client:
|
||||
assert client.initialize_result.capabilities.completions is None
|
||||
assert client.server_capabilities.completions is None
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ async def test_context_logging_helpers_send_log_notifications(connect: Connect)
|
|||
|
||||
async with connect(mcp, logging_callback=collect) as client:
|
||||
result = await client.call_tool("narrate", {})
|
||||
advertised_logging = client.initialize_result.capabilities.logging
|
||||
advertised_logging = client.server_capabilities.logging
|
||||
|
||||
assert result == snapshot(CallToolResult(content=[TextContent(text="done")], structured_content={"result": "done"}))
|
||||
assert received == snapshot(
|
||||
|
|
|
|||
|
|
@ -301,6 +301,7 @@ def test_compute_cells_drops_era_locked_transport_outside_its_versions() -> None
|
|||
"sse-2025-11-25",
|
||||
"streamable-http-2025-11-25",
|
||||
"streamable-http-stateless-2025-11-25",
|
||||
"in-memory-2026-07-28",
|
||||
"streamable-http-2026-07-28",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ async def test_client_tolerates_405_on_get_and_delete() -> None:
|
|||
):
|
||||
transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client)
|
||||
with anyio.fail_after(5): # pragma: no branch
|
||||
async with Client(transport) as client: # pragma: no branch
|
||||
async with Client(transport, mode="legacy") as client: # pragma: no branch
|
||||
result = await client.list_tools()
|
||||
|
||||
assert [tool.name for tool in result.tools] == ["echo"]
|
||||
|
|
@ -240,7 +240,7 @@ async def test_a_404_mid_session_surfaces_as_a_session_terminated_error() -> Non
|
|||
):
|
||||
transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client)
|
||||
with anyio.fail_after(5): # pragma: no branch
|
||||
async with Client(transport) as client: # pragma: no branch
|
||||
async with Client(transport, mode="legacy") as client: # pragma: no branch
|
||||
with pytest.raises(MCPError) as exc_info: # pragma: no branch
|
||||
await client.list_tools()
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from mcp import MCPError
|
|||
from mcp.client.session import ClientSession
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.shared.version import LATEST_MODERN_VERSION
|
||||
from mcp.types import (
|
||||
CLIENT_CAPABILITIES_META_KEY,
|
||||
INTERNAL_ERROR,
|
||||
|
|
@ -28,6 +29,7 @@ from mcp.types import (
|
|||
MISSING_REQUIRED_CLIENT_CAPABILITY,
|
||||
CallToolRequestParams,
|
||||
CallToolResult,
|
||||
DiscoverResult,
|
||||
EmptyResult,
|
||||
Implementation,
|
||||
JSONRPCError,
|
||||
|
|
@ -35,6 +37,7 @@ from mcp.types import (
|
|||
ListToolsResult,
|
||||
PaginatedRequestParams,
|
||||
RequestParams,
|
||||
ServerCapabilities,
|
||||
TextContent,
|
||||
Tool,
|
||||
)
|
||||
|
|
@ -43,8 +46,6 @@ from tests.interaction._requirements import requirement
|
|||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
MODERN_VERSION = "2026-07-28"
|
||||
|
||||
|
||||
def _modern_headers(*, method: str, name: str | None = None) -> dict[str, str]:
|
||||
"""Request headers for a 2026-07-28 POST.
|
||||
|
|
@ -52,7 +53,7 @@ def _modern_headers(*, method: str, name: str | None = None) -> dict[str, str]:
|
|||
The Accept/Content-Type baseline plus the ``MCP-Protocol-Version`` routing header and the
|
||||
``Mcp-Method`` / ``Mcp-Name`` advisory headers a 2026-era client always sends.
|
||||
"""
|
||||
headers = base_headers() | {"mcp-protocol-version": MODERN_VERSION, "mcp-method": method}
|
||||
headers = base_headers() | {"mcp-protocol-version": LATEST_MODERN_VERSION, "mcp-method": method}
|
||||
if name is not None:
|
||||
headers["mcp-name"] = name
|
||||
return headers
|
||||
|
|
@ -65,7 +66,7 @@ def _meta_envelope() -> dict[str, object]:
|
|||
capabilities travel on each request instead of once per session.
|
||||
"""
|
||||
return {
|
||||
"io.modelcontextprotocol/protocolVersion": MODERN_VERSION,
|
||||
"io.modelcontextprotocol/protocolVersion": LATEST_MODERN_VERSION,
|
||||
"io.modelcontextprotocol/clientInfo": {"name": "raw", "version": "0.0.0"},
|
||||
"io.modelcontextprotocol/clientCapabilities": {},
|
||||
}
|
||||
|
|
@ -328,12 +329,16 @@ async def test_pinned_client_stateless_tools_call_round_trips_against_the_modern
|
|||
with anyio.fail_after(5):
|
||||
async with (
|
||||
mounted_app(server, on_request=on_request, on_response=on_response) as (http, _),
|
||||
streamable_http_client(f"{BASE_URL}/mcp", http_client=http, protocol_version=MODERN_VERSION) as (
|
||||
read,
|
||||
write,
|
||||
),
|
||||
ClientSession(read, write, client_info=client_info, protocol_version=MODERN_VERSION) as session,
|
||||
streamable_http_client(f"{BASE_URL}/mcp", http_client=http) as (read, write),
|
||||
ClientSession(read, write, client_info=client_info) as session,
|
||||
):
|
||||
session.adopt(
|
||||
DiscoverResult(
|
||||
supported_versions=[LATEST_MODERN_VERSION],
|
||||
capabilities=ServerCapabilities(),
|
||||
server_info=Implementation(name="srv", version="0"),
|
||||
)
|
||||
)
|
||||
result = await session.call_tool(
|
||||
"add",
|
||||
{"a": 2, "b": 3},
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ from mcp.client.session import ClientSession
|
|||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.shared.message import ClientMessageMetadata
|
||||
from mcp.shared.version import LATEST_HANDSHAKE_VERSION
|
||||
from mcp.types import (
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
CallToolRequest,
|
||||
CallToolRequestParams,
|
||||
CallToolResult,
|
||||
|
|
@ -431,7 +431,7 @@ async def test_a_captured_resumption_token_replays_missed_messages_on_a_new_conn
|
|||
# The session id is only observable via the manager (the client transport does not expose it).
|
||||
(session_id,) = manager._server_instances
|
||||
http.headers["mcp-session-id"] = session_id
|
||||
http.headers["mcp-protocol-version"] = LATEST_PROTOCOL_VERSION
|
||||
http.headers["mcp-protocol-version"] = LATEST_HANDSHAKE_VERSION
|
||||
tg.cancel_scope.cancel()
|
||||
|
||||
with anyio.fail_after(5): # pragma: no branch
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ async def test_legacy_streamable_http_exchange_carries_no_modern_protocol_vocabu
|
|||
|
||||
async with mounted_app(_server(), on_request=on_request, on_response=on_response) as (http, _):
|
||||
recording = RecordingTransport(streamable_http_client(f"{BASE_URL}/mcp", http_client=http))
|
||||
async with Client(recording) as client:
|
||||
async with Client(recording, mode="legacy") as client:
|
||||
result = await client.call_tool("echo", {"text": "legacy"})
|
||||
|
||||
assert result == snapshot(CallToolResult(content=[TextContent(text="legacy")]))
|
||||
|
|
|
|||
|
|
@ -51,10 +51,10 @@ async def test_endpoint_event_names_the_message_endpoint_with_a_fresh_session_id
|
|||
f"{BASE_URL}/sse", httpx_client_factory=httpx_client_factory, on_session_created=captured_session_id.append
|
||||
)
|
||||
with anyio.fail_after(5):
|
||||
async with Client(transport) as client:
|
||||
async with Client(transport, mode="legacy") as client:
|
||||
assert len(captured_session_id) == 1
|
||||
assert UUID(hex=captured_session_id[0]) in sse._read_stream_writers
|
||||
assert await client.send_ping() == snapshot(EmptyResult())
|
||||
assert await client.send_ping() == snapshot(EmptyResult()) # pyright: ignore[reportDeprecated]
|
||||
|
||||
assert sse._read_stream_writers == {}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue