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
22 changed files with 2533 additions and 993 deletions

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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"]

View file

@ -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)

View 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

View file

@ -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": ""})

View file

@ -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={}))

View file

@ -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())

View file

@ -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)

View file

@ -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", {})

View file

@ -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 = [

View file

@ -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

View file

@ -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(

View file

@ -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",
]

View file

@ -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()

View file

@ -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},

View file

@ -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

View file

@ -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")]))

View file

@ -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