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

View file

@ -17,6 +17,9 @@ Scenarios:
initialize - Connect, initialize, list tools, close initialize - Connect, initialize, list tools, close
tools_call - Connect, call add_numbers(a=5, b=3), close tools_call - Connect, call add_numbers(a=5, b=3), close
sse-retry - Connect, call test_reconnection, close sse-retry - Connect, call test_reconnection, close
json-schema-ref-no-deref - Connect, list tools (no $ref deref)
request-metadata - Connect with all callbacks; client stamps _meta
http-standard-headers - Connect, call a tool (Mcp-* headers checked)
elicitation-sep1034-client-defaults - Elicitation with default accept callback elicitation-sep1034-client-defaults - Elicitation with default accept callback
auth/client-credentials-jwt - Client credentials with private_key_jwt auth/client-credentials-jwt - Client credentials with private_key_jwt
auth/client-credentials-basic - Client credentials with client_secret_basic auth/client-credentials-basic - Client credentials with client_secret_basic
@ -35,16 +38,18 @@ from urllib.parse import parse_qs, urlparse
import httpx import httpx
from pydantic import AnyUrl from pydantic import AnyUrl
from mcp import ClientSession, types from mcp import types
from mcp.client.auth import OAuthClientProvider, TokenStorage from mcp.client.auth import OAuthClientProvider, TokenStorage
from mcp.client.auth.extensions.client_credentials import ( from mcp.client.auth.extensions.client_credentials import (
ClientCredentialsOAuthProvider, ClientCredentialsOAuthProvider,
PrivateKeyJWTOAuthProvider, PrivateKeyJWTOAuthProvider,
SignedJWTParameters, SignedJWTParameters,
) )
from mcp.client.client import Client
from mcp.client.context import ClientRequestContext from mcp.client.context import ClientRequestContext
from mcp.client.streamable_http import streamable_http_client from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata, OAuthToken from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
# Set up logging to stderr (stdout is for conformance test output) # Set up logging to stderr (stdout is for conformance test output)
logging.basicConfig( logging.basicConfig(
@ -58,10 +63,24 @@ logger = logging.getLogger(__name__)
#: "2026-07-28"). The harness always sets this (when --spec-version is omitted #: "2026-07-28"). The harness always sets this (when --spec-version is omitted
#: it picks per-scenario: LATEST_SPEC_VERSION for active scenarios, #: it picks per-scenario: LATEST_SPEC_VERSION for active scenarios,
#: DRAFT_PROTOCOL_VERSION for draft-only ones), so None means we were invoked #: DRAFT_PROTOCOL_VERSION for draft-only ones), so None means we were invoked
#: outside the harness. Handlers that need to take the stateless 2026 path will #: outside the harness.
#: branch on this once the SDK has one; today it is logged only.
PROTOCOL_VERSION: str | None = os.environ.get("MCP_CONFORMANCE_PROTOCOL_VERSION") PROTOCOL_VERSION: str | None = os.environ.get("MCP_CONFORMANCE_PROTOCOL_VERSION")
def client_mode() -> str:
"""Pick the Client(mode=) for the harness leg.
On a modern leg (2026-07-28+) -> 'auto' so Client.discover() runs and the
_meta envelope + MCP-Protocol-Version header are stamped on every request.
On a handshake-era leg -> 'legacy' so the initialize handshake runs exactly
as before (no server/discover probe is sent against a mock that would 400 it).
Outside the harness -> 'auto' (probe + fallback).
"""
if PROTOCOL_VERSION is None or PROTOCOL_VERSION in MODERN_PROTOCOL_VERSIONS:
return "auto"
return "legacy"
# Type for async scenario handler functions # Type for async scenario handler functions
ScenarioHandler = Callable[[str], Coroutine[Any, None, None]] ScenarioHandler = Callable[[str], Coroutine[Any, None, None]]
@ -165,52 +184,22 @@ class ConformanceOAuthCallbackHandler:
return result return result
# --- Scenario Handlers --- # --- Stub callbacks (declare capabilities in _meta without doing real work) ---
@register("initialize") async def stub_sampling_callback(
async def run_initialize(server_url: str) -> None: context: ClientRequestContext,
"""Connect, initialize, list tools, close.""" params: types.CreateMessageRequestParams,
async with streamable_http_client(url=server_url) as (read_stream, write_stream): ) -> types.CreateMessageResult | types.ErrorData:
async with ClientSession(read_stream, write_stream) as session: return types.CreateMessageResult(
await session.initialize() role="assistant",
logger.debug("Initialized successfully") content=types.TextContent(type="text", text=""),
await session.list_tools() model="conformance-stub",
logger.debug("Listed tools successfully") )
@register("json-schema-ref-no-deref") async def stub_list_roots_callback(context: ClientRequestContext) -> types.ListRootsResult | types.ErrorData:
async def run_json_schema_ref_no_deref(server_url: str) -> None: return types.ListRootsResult(roots=[])
"""Initialize and list tools; the scenario fails only if the client fetches a network $ref.
ClientSession never walks inputSchema or resolves $refs, so listing is enough (SEP-2106).
"""
async with streamable_http_client(url=server_url) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
await session.list_tools()
@register("tools_call")
async def run_tools_call(server_url: str) -> None:
"""Connect, initialize, list tools, call add_numbers(a=5, b=3), close."""
async with streamable_http_client(url=server_url) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
await session.list_tools()
result = await session.call_tool("add_numbers", {"a": 5, "b": 3})
logger.debug(f"add_numbers result: {result}")
@register("sse-retry")
async def run_sse_retry(server_url: str) -> None:
"""Connect, initialize, list tools, call test_reconnection, close."""
async with streamable_http_client(url=server_url) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
await session.list_tools()
result = await session.call_tool("test_reconnection", {})
logger.debug(f"test_reconnection result: {result}")
async def default_elicitation_callback( async def default_elicitation_callback(
@ -233,17 +222,87 @@ async def default_elicitation_callback(
return types.ElicitResult(action="accept", content=content) return types.ElicitResult(action="accept", content=content)
# --- Scenario Handlers ---
@register("initialize")
async def run_initialize(server_url: str) -> None:
"""Connect, initialize, list tools, close."""
async with Client(server_url, mode=client_mode()) as client:
logger.debug("Initialized successfully")
await client.list_tools()
logger.debug("Listed tools successfully")
@register("json-schema-ref-no-deref")
async def run_json_schema_ref_no_deref(server_url: str) -> None:
"""Initialize and list tools; the scenario fails only if the client fetches a network $ref.
The client never walks inputSchema or resolves $refs, so listing is enough (SEP-2106).
Pinned to mode='legacy': the harness reports PROTOCOL_VERSION=2026-07-28 for this
scenario but its mock server only speaks the handshake-era lifecycle and 400s a
modern-stamped tools/list. The check is lifecycle-agnostic so this is harmless.
"""
async with Client(server_url, mode="legacy") as client:
await client.list_tools()
@register("tools_call")
async def run_tools_call(server_url: str) -> None:
"""Connect, list tools, call add_numbers(a=5, b=3), close."""
async with Client(server_url, mode=client_mode()) as client:
await client.list_tools()
result = await client.call_tool("add_numbers", {"a": 5, "b": 3})
logger.debug(f"add_numbers result: {result}")
@register("sse-retry")
async def run_sse_retry(server_url: str) -> None:
"""Connect, list tools, call test_reconnection, close."""
async with Client(server_url, mode=client_mode()) as client:
await client.list_tools()
result = await client.call_tool("test_reconnection", {})
logger.debug(f"test_reconnection result: {result}")
@register("request-metadata")
async def run_request_metadata(server_url: str) -> None:
"""Connect on the modern path with every client capability declared.
The scenario inspects every request's `_meta` envelope (SEP-2575) for
protocolVersion / clientInfo / clientCapabilities, and the matching
MCP-Protocol-Version header. mode='auto' makes the SDK send
server/discover (covering the unsupported-version retry check), then adopt
and stamp the envelope on the follow-up requests.
"""
async with Client(
server_url,
mode=client_mode(),
sampling_callback=stub_sampling_callback,
list_roots_callback=stub_list_roots_callback,
elicitation_callback=default_elicitation_callback,
) as client:
await client.list_tools()
result = await client.call_tool("add_numbers", {"a": 5, "b": 3})
logger.debug(f"add_numbers result: {result}")
@register("http-standard-headers")
async def run_http_standard_headers(server_url: str) -> None:
"""Connect on the modern path so Mcp-Method / Mcp-Name / MCP-Protocol-Version are sent (SEP-2243)."""
async with Client(server_url, mode=client_mode()) as client:
await client.list_tools()
result = await client.call_tool("add_numbers", {"a": 5, "b": 3})
logger.debug(f"add_numbers result: {result}")
@register("elicitation-sep1034-client-defaults") @register("elicitation-sep1034-client-defaults")
async def run_elicitation_defaults(server_url: str) -> None: async def run_elicitation_defaults(server_url: str) -> None:
"""Connect with elicitation callback that applies schema defaults.""" """Connect with elicitation callback that applies schema defaults."""
async with streamable_http_client(url=server_url) as (read_stream, write_stream): async with Client(server_url, mode=client_mode(), elicitation_callback=default_elicitation_callback) as client:
async with ClientSession( await client.list_tools()
read_stream, write_stream, elicitation_callback=default_elicitation_callback result = await client.call_tool("test_client_elicitation_defaults", {})
) as session: logger.debug(f"test_client_elicitation_defaults result: {result}")
await session.initialize()
await session.list_tools()
result = await session.call_tool("test_client_elicitation_defaults", {})
logger.debug(f"test_client_elicitation_defaults result: {result}")
@register("auth/client-credentials-jwt") @register("auth/client-credentials-jwt")
@ -343,25 +402,22 @@ async def run_auth_code_client(server_url: str) -> None:
async def _run_auth_session(server_url: str, oauth_auth: OAuthClientProvider) -> None: async def _run_auth_session(server_url: str, oauth_auth: OAuthClientProvider) -> None:
"""Common session logic for all OAuth flows.""" """Common session logic for all OAuth flows."""
client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0) http_client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0)
async with streamable_http_client(url=server_url, http_client=client) as (read_stream, write_stream): transport = streamable_http_client(url=server_url, http_client=http_client)
async with ClientSession( async with Client(transport, mode=client_mode(), elicitation_callback=default_elicitation_callback) as client:
read_stream, write_stream, elicitation_callback=default_elicitation_callback logger.debug("Initialized successfully")
) as session:
await session.initialize()
logger.debug("Initialized successfully")
tools_result = await session.list_tools() tools_result = await client.list_tools()
logger.debug(f"Listed tools: {[t.name for t in tools_result.tools]}") logger.debug(f"Listed tools: {[t.name for t in tools_result.tools]}")
# Call the first available tool (different tests have different tools) # Call the first available tool (different tests have different tools)
if tools_result.tools: if tools_result.tools:
tool_name = tools_result.tools[0].name tool_name = tools_result.tools[0].name
try: try:
result = await session.call_tool(tool_name, {}) result = await client.call_tool(tool_name, {})
logger.debug(f"Called {tool_name}, result: {result}") logger.debug(f"Called {tool_name}, result: {result}")
except Exception as e: except Exception as e:
logger.debug(f"Tool call result/error: {e}") logger.debug(f"Tool call result/error: {e}")
logger.debug("Connection closed successfully") logger.debug("Connection closed successfully")
@ -374,7 +430,7 @@ def main() -> None:
server_url = sys.argv[1] server_url = sys.argv[1]
scenario = os.environ.get("MCP_CONFORMANCE_SCENARIO") scenario = os.environ.get("MCP_CONFORMANCE_SCENARIO")
logger.debug(f"Conformance protocol version: {PROTOCOL_VERSION!r}") logger.debug(f"Conformance protocol version: {PROTOCOL_VERSION!r} -> mode={client_mode()!r}")
if scenario: if scenario:
logger.debug(f"Running explicit scenario '{scenario}' against {server_url}") logger.debug(f"Running explicit scenario '{scenario}' against {server_url}")
@ -384,6 +440,9 @@ def main() -> None:
elif scenario.startswith("auth/"): elif scenario.startswith("auth/"):
asyncio.run(run_auth_code_client(server_url)) asyncio.run(run_auth_code_client(server_url))
else: else:
# Unhandled scenarios:
# - sep-2322-client-request-state (SEP-2322 / S6: MRTR client loop)
# - http-custom-headers, http-invalid-tool-headers (SEP-2243 / S8: Mcp-Param-* headers)
print(f"Unknown scenario: {scenario}", file=sys.stderr) print(f"Unknown scenario: {scenario}", file=sys.stderr)
sys.exit(1) sys.exit(1)
else: else:

View file

@ -21,36 +21,13 @@
# milestone. # milestone.
client: client:
# --- No stateless client path on main yet ---
# client.py drives the 2025 stateful lifecycle (initialize handshake +
# session). The 2026-mode mock server is stateless, so the call sequence
# never reaches the assertion. Unblocks when client.py's is_modern_protocol()
# branch takes the per-request _meta path.
- tools_call
# --- Auth scenarios cut short by the 2026 connection lifecycle ---
# The auth fixture flow drives the 2025 stateful lifecycle; the 2026-mode
# mock rejects the MCP POST before the scope-escalation behaviour these
# scenarios measure, so no authorization requests are observed. Unblocks
# when client.py's auth flow speaks the 2026 per-request lifecycle.
- auth/scope-step-up
- auth/scope-retry-limit
# --- Same gaps as the 2025 baseline (fail identically when forced to 2026-07-28) --- # --- Same gaps as the 2025 baseline (fail identically when forced to 2026-07-28) ---
# SEP-2575 (request metadata / _meta envelope): client does not populate the
# _meta envelope or the MCP-Protocol-Version header semantics yet.
- request-metadata
# SEP-2322 (multi-round-trip requests): client does not echo requestState / # SEP-2322 (multi-round-trip requests): client does not echo requestState /
# handle IncompleteResult yet. # handle IncompleteResult yet.
- sep-2322-client-request-state - sep-2322-client-request-state
# SEP-2243 (HTTP standardization): no fixture handler / client header support yet. # SEP-2243 (HTTP standardization): no fixture handler / client Mcp-Param-* support yet.
- http-custom-headers - http-custom-headers
- http-invalid-tool-headers - http-invalid-tool-headers
# SEP-2352 (authorization server migration): the client re-registers and does not reuse the old
# AS credentials, but the 2026-mode mock rejects the MCP POST before the migration 401 fires
# (client.py drives the 2025 stateful lifecycle), so the re-register check is never reached.
# Unblocks with the 2026 stateless client lifecycle.
- auth/authorization-server-migration
# auth/enterprise-managed-authorization (SEP-990) is in the 2025 baseline but # auth/enterprise-managed-authorization (SEP-990) is in the 2025 baseline but
# NOT here: the harness skips it as inapplicable at --spec-version 2026-07-28 # NOT here: the harness skips it as inapplicable at --spec-version 2026-07-28
# (it is an extension scenario not carried into the 2026 wire), so it is # (it is an extension scenario not carried into the 2026 wire), so it is

View file

@ -12,20 +12,12 @@
client: client:
# --- Draft-spec scenarios (in `--suite draft`, also part of `--suite all`) --- # --- Draft-spec scenarios (in `--suite draft`, also part of `--suite all`) ---
# SEP-2575 (request metadata / _meta envelope): client does not populate the
# _meta envelope or the MCP-Protocol-Version header semantics yet.
- request-metadata
# SEP-2322 (multi-round-trip requests): client does not echo requestState / # SEP-2322 (multi-round-trip requests): client does not echo requestState /
# handle IncompleteResult yet. # handle IncompleteResult yet.
- sep-2322-client-request-state - sep-2322-client-request-state
# SEP-2243 (HTTP standardization): no fixture handler / client header support yet. # SEP-2243 (HTTP standardization): no fixture handler / client Mcp-Param-* support yet.
- http-custom-headers - http-custom-headers
- http-invalid-tool-headers - http-invalid-tool-headers
# SEP-2352 (authorization server migration): the client re-registers and does not reuse the old
# AS credentials, but this 2026-introduced scenario runs at 2026-07-28, where client.py's 2025
# stateful lifecycle is rejected (400 on initialize) before the migration 401 fires, so the
# re-register check is never reached. Unblocks with the 2026 stateless client lifecycle.
- auth/authorization-server-migration
# --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 --- # --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 ---
# SEP-990 (enterprise-managed authorization extension): no fixture handler / # SEP-990 (enterprise-managed authorization extension): no fixture handler /

View file

@ -159,6 +159,12 @@ The `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters have been re
Note: `sse_client` retains its `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters — only the streamable HTTP transport changed. Note: `sse_client` retains its `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters — only the streamable HTTP transport changed.
### `StreamableHTTPTransport.protocol_version` attribute removed
The transport no longer holds per-connection protocol state; era-dependent headers (e.g. `MCP-Protocol-Version`) are now supplied per-message by the session. If you were reading `transport.protocol_version` to learn the negotiated version, read `session.protocol_version` (or `client.protocol_version` on the high-level `Client`) instead.
The `MCP_PROTOCOL_VERSION` header-name constant has moved: import `MCP_PROTOCOL_VERSION_HEADER` from `mcp.shared.inbound` instead of `MCP_PROTOCOL_VERSION` from `mcp.client.streamable_http`.
### `terminate_windows_process` removed ### `terminate_windows_process` removed
The deprecated `mcp.os.win32.utilities.terminate_windows_process` function has been The deprecated `mcp.os.win32.utilities.terminate_windows_process` function has been
@ -326,9 +332,9 @@ result = await session.list_resources(params=PaginatedRequestParams(cursor="next
result = await session.list_tools(params=PaginatedRequestParams(cursor="next_page_token")) result = await session.list_tools(params=PaginatedRequestParams(cursor="next_page_token"))
``` ```
### `ClientSession.get_server_capabilities()` replaced by `initialize_result` property ### `ClientSession.get_server_capabilities()` replaced by era-neutral accessors
`ClientSession` now stores the full `InitializeResult` via an `initialize_result` property. This provides access to `server_info`, `capabilities`, `instructions`, and the negotiated `protocol_version` through a single property. The `get_server_capabilities()` method has been removed. `ClientSession` now exposes the negotiated server metadata as properties: `server_capabilities`, `server_info`, `instructions`, and `protocol_version`. These are populated by whichever connection step ran (`initialize()` for ≤2025-11-25 servers, `discover()` for 2026-07-28+), and are `None` if none has — matching v1's `get_server_capabilities()`. The `get_server_capabilities()` method has been removed.
**Before (v1):** **Before (v1):**
@ -340,15 +346,23 @@ capabilities = session.get_server_capabilities()
**After (v2):** **After (v2):**
```python ```python
result = session.initialize_result capabilities = session.server_capabilities
if result is not None: server_info = session.server_info
capabilities = result.capabilities instructions = session.instructions
server_info = result.server_info version = session.protocol_version
instructions = result.instructions
version = result.protocol_version
``` ```
The high-level `Client.initialize_result` returns the same `InitializeResult` but is non-nullable — initialization is guaranteed inside the context manager, so no `None` check is needed. This replaces v1's `Client.server_capabilities`; use `client.initialize_result.capabilities` instead. The raw handshake result is also retained: `session.initialize_result` is set after `initialize()` (≤2025-11-25 servers — including `stateless_http=True` servers, which still answer `initialize`); `session.discover_result` is set after `discover()` (2026-07-28+ servers). At most one is non-`None`.
On the high-level `Client`, `client.server_capabilities`, `client.server_info`, and `client.protocol_version` are non-nullable inside the context manager. `client.instructions` remains `str | None` since the server may omit it. (The lowlevel `ClientSession` still lets you call methods before any handshake, as in v1; `Client` always connects on enter — by default it probes `server/discover` and falls back to the initialize handshake.)
### `Client` defaults to `mode='auto'`
In v1, connecting to a server always performed the `initialize` handshake. In v2, `Client` defaults to `mode='auto'`: on enter it probes `server/discover` and, if the server doesn't support it, falls back to the `initialize` handshake. Pass `mode='legacy'` to force the initialize handshake and reproduce v1's byte-identical pre-2026 behavior, or pass a modern protocol-version string (e.g. `mode='2026-07-28'`) to pin a version without probing.
For an in-process `Client(server)` (where `server` is a `Server` or `MCPServer` instance), `mode='auto'` dispatches calls directly through `DirectDispatcher` with no JSON-RPC framing. Pass `mode='legacy'` if you need the in-memory JSON-RPC transport that v1 used.
`Client.send_ping()` is deprecated (ping is removed in 2026-07-28); pin `mode='legacy'` if you need it.
### `McpError` renamed to `MCPError` ### `McpError` renamed to `MCPError`
@ -764,6 +778,10 @@ async def my_tool(ctx: Context) -> str: ...
async def my_tool(ctx: Context[MyLifespanState]) -> str: ... async def my_tool(ctx: Context[MyLifespanState]) -> str: ...
``` ```
### Version constants
`SUPPORTED_PROTOCOL_VERSIONS` is deprecated — it's now the union of `HANDSHAKE_PROTOCOL_VERSIONS` (initialize-handshake versions) and `MODERN_PROTOCOL_VERSIONS` (per-request-envelope versions). If you were using it to mean "versions the initialize handshake accepts", switch to `HANDSHAKE_PROTOCOL_VERSIONS`. Named scalars derived from these tuples are now exported alongside them — `LATEST_HANDSHAKE_VERSION`, `LATEST_MODERN_VERSION`, `OLDEST_SUPPORTED_VERSION` — so prefer those over indexing the tuples directly.
### `ProgressContext` and `progress()` context manager removed ### `ProgressContext` and `progress()` context manager removed
The `mcp.shared.progress` module (`ProgressContext`, `Progress`, and the `progress()` context manager) has been removed. This module had no real-world adoption — all users send progress notifications via `Context.report_progress()` or `session.send_progress_notification()` directly. The `mcp.shared.progress` module (`ProgressContext`, `Progress`, and the `progress()` context manager) has been removed. This module had no real-world adoption — all users send progress notifications via `Context.report_progress()` or `session.send_progress_notification()` directly.
@ -796,6 +814,12 @@ await session.send_progress_notification(
) )
``` ```
### Handler progress reporting: prefer `ctx.report_progress()` over manual `progress_token`
Reading `ctx.meta["progress_token"]` and calling `session.send_progress_notification(token, ...)` is specific to the JSON-RPC transport path. On the in-process modern path (`DirectDispatcher` / `Client(server)`), there is no wire token in `_meta`, so handlers that gate progress on the token's presence go silent.
`ctx.report_progress(progress, total, message)` works on every dispatcher: it sends a progress notification when a token is present and routes the update through the dispatcher's progress channel otherwise, no-opping only when the caller did not request progress at all. `session.send_progress_notification(progress_token, ...)` is unchanged and still works on JSON-RPC transports for code that already holds a token.
### `create_connected_server_and_client_session` removed ### `create_connected_server_and_client_session` removed
The `create_connected_server_and_client_session` helper in `mcp.shared.memory` has been removed. Use `mcp.client.Client` instead — it accepts a `Server` or `MCPServer` instance directly and handles the in-memory transport and session setup for you. The `create_connected_server_and_client_session` helper in `mcp.shared.memory` has been removed. Use `mcp.client.Client` instead — it accepts a `Server` or `MCPServer` instance directly and handles the in-memory transport and session setup for you.
@ -818,7 +842,7 @@ async with Client(server) as client:
result = await client.call_tool("my_tool", {"x": 1}) result = await client.call_tool("my_tool", {"x": 1})
``` ```
`Client` accepts the same callback parameters the old helper did (`sampling_callback`, `list_roots_callback`, `logging_callback`, `message_handler`, `elicitation_callback`, `client_info`) plus `raise_exceptions` to surface server-side errors. `Client` accepts the same callback parameters the old helper did (`sampling_callback`, `list_roots_callback`, `logging_callback`, `message_handler`, `elicitation_callback`, `client_info`) plus `raise_exceptions` to surface server-side errors and `mode` to control version negotiation (`'auto'` by default; `'legacy'` reproduces v1's initialize-only handshake).
If you need direct access to the underlying `ClientSession` and memory streams (e.g., for low-level transport testing), `create_client_server_memory_streams` is still available in `mcp.shared.memory`: If you need direct access to the underlying `ClientSession` and memory streams (e.g., for low-level transport testing), `create_client_server_memory_streams` is still available in `mcp.shared.memory`:
@ -1289,6 +1313,12 @@ warnings.filterwarnings("ignore", category=MCPDeprecationWarning)
No migration is required during the deprecation window. New code should avoid building on these features, since they may be removed in a future spec version. No migration is required during the deprecation window. New code should avoid building on these features, since they may be removed in a future spec version.
### Client-to-server progress deprecated (2026-07-28)
The 2026-07-28 spec restricts `notifications/progress` to the server-to-client direction only — `ProgressNotification` is no longer in `ClientNotification`. `Client.send_progress_notification()` and `ClientSession.send_progress_notification()` now carry `typing_extensions.deprecated` and emit `mcp.MCPDeprecationWarning` at runtime. They continue to work against servers negotiating 2025-11-25 or earlier.
On the server side, prefer the new dispatcher-agnostic `ServerSession.report_progress(progress, total, message)` (and `Context.report_progress()` on `MCPServer`) over the raw `ServerSession.send_progress_notification(progress_token, …)`. `report_progress` encapsulates the "no-op when the caller did not request progress" rule and works on every dispatcher; the raw token-taking form remains for handlers that read `_meta.progressToken` directly.
## Bug Fixes ## Bug Fixes
### OAuth metadata URLs no longer gain a trailing slash ### OAuth metadata URLs no longer gain a trailing slash

View file

@ -74,4 +74,9 @@ async def test_call_add_tool(client: Client):
1. If you are using `trio`, you should set `"trio"` as the `anyio_backend`. Check more information in the [anyio documentation](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on). 1. If you are using `trio`, you should set `"trio"` as the `anyio_backend`. Check more information in the [anyio documentation](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on).
2. The `client` fixture creates a connected client that can be reused across multiple tests. 2. The `client` fixture creates a connected client that can be reused across multiple tests.
!!! note
`Client(app)` connects in-process and is era-neutral by default — it probes the server and picks the
appropriate protocol path. Pin `mode='legacy'` if your test exercises legacy-specific semantics
(sampling/elicitation push, `message_handler`).
There you go! You can now extend your tests to cover more scenarios. There you go! You can now extend your tests to cover more scenarios.

View file

@ -217,6 +217,12 @@ filterwarnings = [
# them internally (e.g. `ctx.debug` -> `log` -> `send_log_message`), so the # them internally (e.g. `ctx.debug` -> `log` -> `send_log_message`), so the
# advisory warning is silenced. Tests asserting it opt back in with pytest.warns. # advisory warning is silenced. Tests asserting it opt back in with pytest.warns.
"ignore:.*is deprecated as of 2026-07-28 \\(SEP-2577\\).:mcp.MCPDeprecationWarning", "ignore:.*is deprecated as of 2026-07-28 \\(SEP-2577\\).:mcp.MCPDeprecationWarning",
# 2026-07-28 restricts progress to server->client; the client send path is
# advisory-deprecated and a handful of tests still exercise it.
"ignore:Client-to-server progress is deprecated as of 2026-07-28.*:mcp.MCPDeprecationWarning",
# 2026-07-28 drops ping; Client.send_ping() is advisory-deprecated and the
# legacy interaction/transport tests still drive it.
"ignore:ping is removed as of 2026-07-28.*:mcp.MCPDeprecationWarning",
] ]
[tool.markdown.lint] [tool.markdown.lint]

79
src/mcp/client/_probe.py Normal file
View file

@ -0,0 +1,79 @@
"""Connect-time era negotiation for ``mode='auto'``.
The ``server/discover`` probe is sent at the newest modern version. Anything
that is not positive evidence the peer is a modern MCP server falls back to
the legacy ``initialize`` handshake — a *denylist* (only the disjoint-modern
case raises) rather than an allowlist of fallback codes.
Every ``MCPError`` falls back except ``-32022`` with a disjoint modern-only
``supported`` list. The streamable-HTTP transport already maps HTTP-layer
4xx rejections (no JSON-RPC body) into ``MCPError`` codes, so those reach
the same path. Any non-``MCPError`` exception (network/connection errors,
anyio cancellation, the ``RuntimeError`` from ``adopt()`` on no-mutual)
propagates to the caller; an outage or in-process bug is never an era verdict.
"""
from __future__ import annotations
from typing import Any
from pydantic import ValidationError
from mcp import types
from mcp.client.session import ClientSession
from mcp.shared.exceptions import MCPError
from mcp.shared.version import (
HANDSHAKE_PROTOCOL_VERSIONS,
LATEST_MODERN_VERSION,
MODERN_PROTOCOL_VERSIONS,
)
from mcp.types import UNSUPPORTED_PROTOCOL_VERSION
def _parse_supported(data: Any) -> list[str] | None:
"""Pull ``data.supported`` off a -32022 error, or ``None`` if not actionable."""
try:
return types.UnsupportedProtocolVersionErrorData.model_validate(data).supported
except ValidationError:
return None
async def negotiate_auto(session: ClientSession) -> None:
"""Drive the ``mode='auto'`` connect-time policy on ``session``.
Probes ``server/discover`` once (twice if the server names a mutual
modern version via -32022), then either ``adopt()``s the result or falls
back to ``initialize()``. Idempotent only in the sense that one of
``session.discover_result`` / ``session.initialize_result`` is set on
return.
Raises:
MCPError: The server is modern-only and shares no version with this
client (-32022 with a disjoint ``supported`` list).
Exception: Any transport/network error from the probe propagates as-is.
"""
version = LATEST_MODERN_VERSION
for attempt in range(2):
try:
raw = await session.send_discover(version)
except MCPError as e:
if e.code == UNSUPPORTED_PROTOCOL_VERSION:
supported = _parse_supported(e.error.data)
mutual = [v for v in MODERN_PROTOCOL_VERSIONS if v in (supported or ())]
if mutual and attempt == 0:
version = mutual[-1]
continue
if supported is not None and not any(v in HANDSHAKE_PROTOCOL_VERSIONS for v in supported):
raise # server is modern-only and disjoint — real incompatibility
await session.initialize() # every other rpc-error → legacy (the denylist)
return
# any other exception (httpx.TransportError, ConnectionError, anyio errors,
# RuntimeError from adopt) → propagate
try:
result = types.DiscoverResult.model_validate(raw)
except ValidationError:
await session.initialize() # unparseable result → not modern evidence
return
session.adopt(result)
return
raise AssertionError("unreachable") # pragma: no cover — loop body always returns or raises

View file

@ -40,7 +40,6 @@ from mcp.client.auth.utils import (
validate_authorization_response_iss, validate_authorization_response_iss,
validate_metadata_issuer, validate_metadata_issuer,
) )
from mcp.client.streamable_http import MCP_PROTOCOL_VERSION
from mcp.shared.auth import ( from mcp.shared.auth import (
AuthorizationCodeResult, AuthorizationCodeResult,
OAuthClientInformationFull, OAuthClientInformationFull,
@ -54,6 +53,7 @@ from mcp.shared.auth_utils import (
check_resource_allowed, check_resource_allowed,
resource_url_from_server_url, resource_url_from_server_url,
) )
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
from mcp.shared.version import is_version_at_least from mcp.shared.version import is_version_at_least
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -534,7 +534,7 @@ class OAuthClientProvider(httpx.Auth):
await self._initialize() await self._initialize()
# Capture protocol version from request headers # Capture protocol version from request headers
self.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION) self.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION_HEADER)
if not self.context.is_token_valid() and self.context.can_refresh_token(): if not self.context.is_token_valid() and self.context.can_refresh_token():
# Try to refresh token # Try to refresh token

View file

@ -5,7 +5,6 @@ from httpx import Request, Response
from pydantic import AnyUrl, ValidationError from pydantic import AnyUrl, ValidationError
from mcp.client.auth import OAuthFlowError, OAuthRegistrationError, OAuthTokenError from mcp.client.auth import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
from mcp.client.streamable_http import MCP_PROTOCOL_VERSION
from mcp.shared.auth import ( from mcp.shared.auth import (
OAuthClientInformationFull, OAuthClientInformationFull,
OAuthClientMetadata, OAuthClientMetadata,
@ -13,6 +12,7 @@ from mcp.shared.auth import (
OAuthToken, OAuthToken,
ProtectedResourceMetadata, ProtectedResourceMetadata,
) )
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
from mcp.types import LATEST_PROTOCOL_VERSION from mcp.types import LATEST_PROTOCOL_VERSION
@ -273,7 +273,7 @@ def validate_metadata_issuer(oauth_metadata: OAuthMetadata, expected_issuer: str
def create_oauth_metadata_request(url: str) -> Request: def create_oauth_metadata_request(url: str) -> Request:
return Request("GET", url, headers={MCP_PROTOCOL_VERSION: LATEST_PROTOCOL_VERSION}) return Request("GET", url, headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_PROTOCOL_VERSION})
def create_client_registration_request( def create_client_registration_request(

View file

@ -2,27 +2,34 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Awaitable, Callable, Mapping
from contextlib import AsyncExitStack from contextlib import AsyncExitStack
from dataclasses import KW_ONLY, dataclass, field from dataclasses import KW_ONLY, dataclass, field
from typing import Any from typing import Any, Literal, TypeVar
import anyio
from typing_extensions import deprecated from typing_extensions import deprecated
from mcp import types
from mcp.client._memory import InMemoryTransport from mcp.client._memory import InMemoryTransport
from mcp.client._probe import negotiate_auto
from mcp.client._transport import Transport from mcp.client._transport import Transport
from mcp.client.session import ClientSession, ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT from mcp.client.session import ClientSession, ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
from mcp.client.streamable_http import streamable_http_client from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server from mcp.server import Server
from mcp.server.mcpserver import MCPServer from mcp.server.mcpserver import MCPServer
from mcp.shared.dispatcher import ProgressFnT from mcp.server.runner import modern_on_request
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
from mcp.shared.dispatcher import Dispatcher, ProgressFnT
from mcp.shared.exceptions import MCPDeprecationWarning from mcp.shared.exceptions import MCPDeprecationWarning
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS
from mcp.types import ( from mcp.types import (
CallToolResult, CallToolResult,
CompleteResult, CompleteResult,
EmptyResult, EmptyResult,
GetPromptResult, GetPromptResult,
Implementation, Implementation,
InitializeResult,
ListPromptsResult, ListPromptsResult,
ListResourcesResult, ListResourcesResult,
ListResourceTemplatesResult, ListResourceTemplatesResult,
@ -33,8 +40,87 @@ from mcp.types import (
ReadResourceResult, ReadResourceResult,
RequestParamsMeta, RequestParamsMeta,
ResourceTemplateReference, ResourceTemplateReference,
ServerCapabilities,
) )
ConnectMode = Literal["legacy", "auto"] | str
"""``mode=`` value: ``"legacy"`` (initialize handshake), ``"auto"`` (discover, fall back to
initialize), or a modern protocol-version string (adopt directly). The ``str`` arm is for
forward-compat; ``Client.__post_init__`` rejects anything outside that set at construction."""
_T = TypeVar("_T")
_Connector = Callable[[AsyncExitStack, ConnectMode, bool], Awaitable["Dispatcher[Any]"]]
"""Resolved at ``__post_init__`` from the shape of ``server`` alone: enter whatever resources
are needed onto the exit stack and hand back the ``Dispatcher`` ``ClientSession`` will drive.
``mode`` and ``raise_exceptions`` are passed at call time so they're read at the same moment
``__aenter__`` reads them for the handshake step."""
def _connect_transport(transport: Transport) -> _Connector:
"""Connector for the stream-backed paths (URL, user-supplied ``Transport``)."""
async def connect(exit_stack: AsyncExitStack, _mode: ConnectMode, _raise_exceptions: bool) -> Dispatcher[Any]:
read_stream, write_stream = await exit_stack.enter_async_context(transport)
return JSONRPCDispatcher(read_stream, write_stream)
return connect
def _connect_inproc(server: Server[Any]) -> _Connector:
"""Connector for an in-process ``Server``: legacy mode drives the stream loop via
``InMemoryTransport``; any other mode drives the modern per-request path through a
``DirectDispatcher`` peer pair (no streams, no JSON-RPC framing, no initialize handshake)."""
async def connect(exit_stack: AsyncExitStack, mode: ConnectMode, raise_exceptions: bool) -> Dispatcher[Any]:
if mode == "legacy":
transport = InMemoryTransport(server, raise_exceptions=raise_exceptions)
read_stream, write_stream = await exit_stack.enter_async_context(transport)
return JSONRPCDispatcher(read_stream, write_stream)
lifespan_state = await exit_stack.enter_async_context(server.lifespan(server))
client_disp, server_disp = create_direct_dispatcher_pair(raise_handler_exceptions=raise_exceptions)
tg = await exit_stack.enter_async_context(anyio.create_task_group())
exit_stack.callback(server_disp.close)
on_request = modern_on_request(server, lifespan_state)
await tg.start(server_disp.run, on_request, _no_inbound_client_notifications)
return client_disp
return connect
def _connected(value: _T | None) -> _T:
"""Narrow a post-handshake session attribute from ``T | None`` to ``T``.
``Client.__aenter__`` only assigns ``_session`` after the handshake succeeds, so inside
``async with Client(...)`` these attributes are always populated; the ``.session`` gate
raises before this is reached otherwise. The guard exists for pyright, not runtime.
"""
if value is None: # pragma: no cover
raise RuntimeError("Client must be used within an async context manager")
return value
def _synthesize_discover(protocol_version: str) -> types.DiscoverResult:
return types.DiscoverResult(
supported_versions=[protocol_version],
capabilities=types.ServerCapabilities(),
server_info=types.Implementation(name="", version=""),
result_type="complete",
ttl_ms=0,
cache_scope="public",
)
async def _no_inbound_client_notifications(_dctx: Any, _method: str, _params: Mapping[str, Any] | None) -> None:
"""Server-side inbound ``OnNotify`` for the modern in-process path — receives nothing.
At 2026-07-28 the spec defines no client→server notifications: ``initialized`` and
``roots/list_changed`` are removed, and cancellation is structural (anyio scope cancel
through the direct await, not a notify). Server→client notifications (progress, log
messages) flow the other way via the per-request ``DispatchContext`` into the client's
callbacks, and are not seen here.
"""
@dataclass @dataclass
class Client: class Client:
@ -65,7 +151,7 @@ class Client:
server: Server[Any] | MCPServer | Transport | str server: Server[Any] | MCPServer | Transport | str
"""The MCP server to connect to. """The MCP server to connect to.
If the server is a `Server` or `MCPServer` instance, it will be wrapped in an `InMemoryTransport`. If the server is a `Server` or `MCPServer` instance, it will be connected in-process.
If the server is a URL string, it will be used as the URL for a `streamable_http_client` transport. If the server is a URL string, it will be used as the URL for a `streamable_http_client` transport.
If the server is a `Transport` instance, it will be used directly. If the server is a `Transport` instance, it will be used directly.
""" """
@ -95,58 +181,83 @@ class Client:
client_info: Implementation | None = None client_info: Implementation | None = None
"""Client implementation info to send to server.""" """Client implementation info to send to server."""
protocol_version: str | None = None mode: ConnectMode = "auto"
"""Pin the protocol version instead of negotiating it. """How to negotiate the protocol version.
Pinning to ``2026-07-28`` or later selects the stateless transport era: no initialize 'auto' (the default) probes `server/discover` and falls back to the initialize handshake on legacy servers;
handshake is sent on the wire (the session synthesizes its `InitializeResult` locally), for an in-process `Server`/`MCPServer` it dispatches directly without JSON-RPC framing. 'legacy' forces the
and for HTTP the ``MCP-Protocol-Version`` header is set from the first request. A modern initialize handshake (byte-identical pre-2026 behavior). A modern protocol-version string (e.g. '2026-07-28')
pin currently requires a URL or `Transport`; the in-memory `Server`/`MCPServer` path adopts that version directly without a probe — supply `prior_discover` to reuse a known DiscoverResult, or
does not yet have a modern entry point. omit it to synthesize a minimal one."""
Leave as ``None`` to negotiate the version via the initialize handshake.
""" prior_discover: types.DiscoverResult | None = None
"""A previously-obtained DiscoverResult to install via .adopt() when mode is a version pin.
Ignored when mode='legacy'."""
elicitation_callback: ElicitationFnT | None = None elicitation_callback: ElicitationFnT | None = None
"""Callback for handling elicitation requests.""" """Callback for handling elicitation requests."""
_entered: bool = field(init=False, default=False)
_session: ClientSession | None = field(init=False, default=None) _session: ClientSession | None = field(init=False, default=None)
_exit_stack: AsyncExitStack | None = field(init=False, default=None) _exit_stack: AsyncExitStack | None = field(init=False, default=None)
_transport: Transport = field(init=False) _connect: _Connector = field(init=False, repr=False, compare=False)
def __post_init__(self) -> None: def __post_init__(self) -> None:
if isinstance(self.server, Server | MCPServer): if self.mode not in ("legacy", "auto") and self.mode not in MODERN_PROTOCOL_VERSIONS:
self._transport = InMemoryTransport(self.server, raise_exceptions=self.raise_exceptions) hint = (
elif isinstance(self.server, str): f" ({self.mode!r} is a handshake-era version — use mode='legacy')"
self._transport = streamable_http_client(self.server, protocol_version=self.protocol_version) if self.mode in HANDSHAKE_PROTOCOL_VERSIONS
else ""
)
raise ValueError(
f"mode must be 'legacy', 'auto', or one of {list(MODERN_PROTOCOL_VERSIONS)}; got {self.mode!r}{hint}"
)
srv = self.server
if isinstance(srv, MCPServer):
srv = srv._lowlevel_server # pyright: ignore[reportPrivateUsage]
if isinstance(srv, Server):
self._connect = _connect_inproc(srv)
elif isinstance(srv, str):
self._connect = _connect_transport(streamable_http_client(srv))
else: else:
self._transport = self.server self._connect = _connect_transport(srv)
async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession:
"""Enter the resolved connector and return an un-entered ClientSession."""
dispatcher = await self._connect(exit_stack, self.mode, self.raise_exceptions)
return ClientSession(
dispatcher=dispatcher,
read_timeout_seconds=self.read_timeout_seconds,
sampling_callback=self.sampling_callback,
list_roots_callback=self.list_roots_callback,
logging_callback=self.logging_callback,
message_handler=self.message_handler,
client_info=self.client_info,
elicitation_callback=self.elicitation_callback,
)
async def __aenter__(self) -> Client: async def __aenter__(self) -> Client:
"""Enter the async context manager.""" """Enter the async context manager."""
if self._session is not None: if self._entered:
raise RuntimeError("Client is already entered; cannot reenter") raise RuntimeError("Client is already entered; cannot reenter")
self._entered = True
async with AsyncExitStack() as exit_stack: async with AsyncExitStack() as exit_stack:
read_stream, write_stream = await exit_stack.enter_async_context(self._transport) session = await self._build_session(exit_stack)
session = await exit_stack.enter_async_context(session)
self._session = await exit_stack.enter_async_context( if self.mode == "legacy":
ClientSession( await session.initialize()
read_stream=read_stream, elif self.mode == "auto":
write_stream=write_stream, await negotiate_auto(session)
read_timeout_seconds=self.read_timeout_seconds, else:
sampling_callback=self.sampling_callback, session.adopt(self.prior_discover or _synthesize_discover(self.mode))
list_roots_callback=self.list_roots_callback,
logging_callback=self.logging_callback,
message_handler=self.message_handler,
client_info=self.client_info,
elicitation_callback=self.elicitation_callback,
protocol_version=self.protocol_version,
)
)
await self._session.initialize() # Only publish the session after the handshake succeeds, so `_session is not None`
# implies the protocol_version/server_info/server_capabilities are populated. If the
# Transfer ownership to self for __aexit__ to handle # handshake raised above, the local exit_stack unwinds the transport for us.
self._session = session
self._exit_stack = exit_stack.pop_all() self._exit_stack = exit_stack.pop_all()
return self return self
@ -169,22 +280,42 @@ class Client:
raise RuntimeError("Client must be used within an async context manager") raise RuntimeError("Client must be used within an async context manager")
return self._session return self._session
# TODO(maxisbey): the by-construction shape is for __aenter__ to return a connected-view
# type whose protocol_version/server_info/server_capabilities are non-Optional fields,
# eliminating these guards (and the one in .session). Same family as resolving the
# transport/connector at __post_init__ so the Optional internal fields disappear.
@property @property
def initialize_result(self) -> InitializeResult: def protocol_version(self) -> str:
"""The server's InitializeResult. """Negotiated protocol version (set by initialize/discover/adopt during ``__aenter__``)."""
return _connected(self.session.protocol_version)
Contains server_info, capabilities, instructions, and the negotiated protocol_version. @property
Raises RuntimeError if accessed outside the context manager. def server_info(self) -> Implementation:
""" """Server name/version (set by initialize/discover/adopt during ``__aenter__``)."""
result = self.session.initialize_result return _connected(self.session.server_info)
if result is None: # pragma: no cover
raise RuntimeError("Client must be used within an async context manager")
return result
@property
def server_capabilities(self) -> ServerCapabilities:
"""Server capabilities (set by initialize/discover/adopt during ``__aenter__``)."""
return _connected(self.session.server_capabilities)
@property
def instructions(self) -> str | None:
"""Server-provided instructions text, if any."""
return self.session.instructions
@deprecated(
"ping is removed as of 2026-07-28; the method only works under mode='legacy'.",
category=MCPDeprecationWarning,
)
async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> EmptyResult: async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> EmptyResult:
"""Send a ping request to the server.""" """Send a ping request to the server."""
return await self.session.send_ping(meta=meta) return await self.session.send_ping(meta=meta)
@deprecated(
"Client-to-server progress is deprecated as of 2026-07-28; progress is server-to-client only.",
category=MCPDeprecationWarning,
)
async def send_progress_notification( async def send_progress_notification(
self, self,
progress_token: str | int, progress_token: str | int,
@ -193,7 +324,7 @@ class Client:
message: str | None = None, message: str | None = None,
) -> None: ) -> None:
"""Send a progress notification to the server.""" """Send a progress notification to the server."""
await self.session.send_progress_notification( await self.session.send_progress_notification( # pyright: ignore[reportDeprecated]
progress_token=progress_token, progress_token=progress_token,
progress=progress, progress=progress,
total=total, total=total,

View file

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from collections.abc import Mapping from collections.abc import Callable, Mapping
from dataclasses import dataclass from dataclasses import dataclass
from types import TracebackType from types import TracebackType
from typing import Any, Protocol, cast from typing import Any, Protocol, cast
@ -17,26 +17,74 @@ from mcp.client._transport import ReadStream, WriteStream
from mcp.shared._compat import resync_tracer from mcp.shared._compat import resync_tracer
from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, ProgressFnT from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, ProgressFnT
from mcp.shared.exceptions import MCPDeprecationWarning, MCPError from mcp.shared.exceptions import MCPDeprecationWarning, MCPError
from mcp.shared.inbound import (
MCP_METHOD_HEADER,
MCP_NAME_HEADER,
MCP_PROTOCOL_VERSION_HEADER,
encode_header_value,
)
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.message import ClientMessageMetadata, SessionMessage from mcp.shared.message import ClientMessageMetadata, SessionMessage
from mcp.shared.session import RequestResponder from mcp.shared.session import RequestResponder
from mcp.shared.transport_context import TransportContext from mcp.shared.transport_context import TransportContext
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS, SUPPORTED_PROTOCOL_VERSIONS from mcp.shared.version import (
HANDSHAKE_PROTOCOL_VERSIONS,
LATEST_HANDSHAKE_VERSION,
LATEST_MODERN_VERSION,
MODERN_PROTOCOL_VERSIONS,
)
from mcp.types import ( from mcp.types import (
CLIENT_CAPABILITIES_META_KEY, CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY, CLIENT_INFO_META_KEY,
INTERNAL_ERROR, INTERNAL_ERROR,
METHOD_NOT_FOUND, METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY, PROTOCOL_VERSION_META_KEY,
UNSUPPORTED_PROTOCOL_VERSION,
RequestId, RequestId,
RequestParamsMeta, RequestParamsMeta,
) )
from mcp.types import methods as _methods from mcp.types import methods as _methods
DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0") DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0")
DISCOVER_TIMEOUT_SECONDS = 10.0
logger = logging.getLogger("client") logger = logging.getLogger("client")
def _preconnect_stamp(data: dict[str, Any], opts: CallOptions) -> None:
# initialize/discover forbid cancellation; other pre-handshake requests (lowlevel
# ClientSession callers may skip the handshake entirely) keep the courtesy cancel.
if data["method"] in ("initialize", "server/discover"):
opts["cancel_on_abandon"] = False
def _make_handshake_stamp(protocol_version: str) -> Callable[[dict[str, Any], CallOptions], None]:
def stamp(data: dict[str, Any], opts: CallOptions) -> None:
opts.setdefault("headers", {})[MCP_PROTOCOL_VERSION_HEADER] = protocol_version
return stamp
def _make_modern_stamp(
protocol_version: str, client_info: dict[str, Any], capabilities: dict[str, Any]
) -> Callable[[dict[str, Any], CallOptions], None]:
def stamp(data: dict[str, Any], opts: CallOptions) -> None:
params = data.setdefault("params", {})
meta = params.setdefault("_meta", {})
meta[PROTOCOL_VERSION_META_KEY] = protocol_version
meta[CLIENT_INFO_META_KEY] = client_info
meta[CLIENT_CAPABILITIES_META_KEY] = capabilities
opts["cancel_on_abandon"] = False
headers = opts.setdefault("headers", {})
headers[MCP_PROTOCOL_VERSION_HEADER] = protocol_version
headers[MCP_METHOD_HEADER] = data["method"]
# TODO: also emit Mcp-Name for prompts/get (params.name) and resources/read (params.uri)
if data["method"] == "tools/call" and isinstance(name := params.get("name"), str):
headers[MCP_NAME_HEADER] = encode_header_value(name)
return stamp
ReceiveResultT = TypeVar("ReceiveResultT", bound=BaseModel) ReceiveResultT = TypeVar("ReceiveResultT", bound=BaseModel)
@ -149,14 +197,11 @@ class ClientSession:
message_handler: MessageHandlerFnT | None = None, message_handler: MessageHandlerFnT | None = None,
client_info: types.Implementation | None = None, client_info: types.Implementation | None = None,
*, *,
protocol_version: str | None = None,
sampling_capabilities: types.SamplingCapability | None = None, sampling_capabilities: types.SamplingCapability | None = None,
dispatcher: Dispatcher[Any] | None = None, dispatcher: Dispatcher[Any] | None = None,
) -> None: ) -> None:
self._session_read_timeout_seconds = read_timeout_seconds self._session_read_timeout_seconds = read_timeout_seconds
self._client_info = client_info or DEFAULT_CLIENT_INFO self._client_info = client_info or DEFAULT_CLIENT_INFO
self._pinned_version = protocol_version
self._stateless_pinned = protocol_version in MODERN_PROTOCOL_VERSIONS
self._sampling_callback = sampling_callback or _default_sampling_callback self._sampling_callback = sampling_callback or _default_sampling_callback
self._sampling_capabilities = sampling_capabilities self._sampling_capabilities = sampling_capabilities
self._elicitation_callback = elicitation_callback or _default_elicitation_callback self._elicitation_callback = elicitation_callback or _default_elicitation_callback
@ -164,24 +209,23 @@ class ClientSession:
self._logging_callback = logging_callback or _default_logging_callback self._logging_callback = logging_callback or _default_logging_callback
self._message_handler = message_handler or _default_message_handler self._message_handler = message_handler or _default_message_handler
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {} self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
self._initialize_result: types.InitializeResult | None self._initialize_result: types.InitializeResult | None = None
if self._stateless_pinned: self._discover_result: types.DiscoverResult | None = None
assert protocol_version is not None self._negotiated_version: str | None = None
# A stateless-pinned session is born initialized: there is no handshake self._stamp: Callable[[dict[str, Any], CallOptions], None] = _preconnect_stamp
# at 2026-07-28+, so we synthesize the result locally. `server_info` is a
# placeholder until `server/discover` is implemented to populate it.
self._initialize_result = types.InitializeResult(
protocol_version=protocol_version,
capabilities=types.ServerCapabilities(),
server_info=types.Implementation(name="", version=""),
)
else:
self._initialize_result = None
self._task_group: anyio.abc.TaskGroup | None = None self._task_group: anyio.abc.TaskGroup | None = None
if dispatcher is not None: if dispatcher is not None:
if read_stream is not None or write_stream is not None: if read_stream is not None or write_stream is not None:
raise ValueError("pass read_stream/write_stream or dispatcher, not both") raise ValueError("pass read_stream/write_stream or dispatcher, not both")
self._dispatcher: Dispatcher[Any] = dispatcher self._dispatcher: Dispatcher[Any] = dispatcher
if isinstance(dispatcher, JSONRPCDispatcher) and dispatcher.on_stream_exception is None:
# Route transport-level Exception items into message_handler — only
# stream-backed dispatchers carry these; DirectDispatcher has none.
# Don't clobber a caller-supplied hook.
# TODO(L78): this leaves a bound-method ref on the dispatcher after the
# session exits (memory pin) and a second wrap of the same dispatcher would
# skip install. The Transport-as-Dispatcher rework (L77) removes this seam.
dispatcher.on_stream_exception = self._on_stream_exception
else: else:
if read_stream is None or write_stream is None: if read_stream is None or write_stream is None:
raise ValueError("read_stream and write_stream are required when no dispatcher is given") raise ValueError("read_stream and write_stream are required when no dispatcher is given")
@ -242,19 +286,7 @@ class ClientSession:
data = request.model_dump(by_alias=True, mode="json", exclude_none=True) data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
method: str = data["method"] method: str = data["method"]
opts: CallOptions = {} opts: CallOptions = {}
if self._stateless_pinned: self._stamp(data, opts)
params = data.setdefault("params", {})
envelope_meta = params.setdefault("_meta", {})
envelope_meta[PROTOCOL_VERSION_META_KEY] = self._pinned_version
envelope_meta[CLIENT_INFO_META_KEY] = self._client_info.model_dump(
by_alias=True, mode="json", exclude_none=True
)
envelope_meta[CLIENT_CAPABILITIES_META_KEY] = self._build_capabilities().model_dump(
by_alias=True, mode="json", exclude_none=True
)
# Stateless pinned mode: disconnect-as-cancel is the spec mechanism, so the
# dispatcher must not emit notifications/cancelled when the caller abandons.
opts["cancel_on_abandon"] = False
timeout = ( timeout = (
request_read_timeout_seconds request_read_timeout_seconds
if request_read_timeout_seconds is not None if request_read_timeout_seconds is not None
@ -269,12 +301,9 @@ class ClientSession:
opts["resumption_token"] = metadata.resumption_token opts["resumption_token"] = metadata.resumption_token
if metadata.on_resumption_token_update is not None: if metadata.on_resumption_token_update is not None:
opts["on_resumption_token"] = metadata.on_resumption_token_update opts["on_resumption_token"] = metadata.on_resumption_token_update
if method == "initialize":
# The spec forbids cancelling initialize.
opts["cancel_on_abandon"] = False
raw = await self._dispatcher.send_raw_request(method, data.get("params"), opts) raw = await self._dispatcher.send_raw_request(method, data.get("params"), opts)
# Literal fallback covers pre-handshake and stateless; matches runner.py. # Literal fallback covers pre-handshake and stateless; matches runner.py.
version = self.protocol_version or "2025-11-25" version = self._negotiated_version or "2025-11-25"
try: try:
_methods.validate_server_result(method, version, raw) _methods.validate_server_result(method, version, raw)
except KeyError: except KeyError:
@ -288,7 +317,9 @@ class ClientSession:
dropped with a debug log instead of raising. dropped with a debug log instead of raising.
""" """
data = notification.model_dump(by_alias=True, mode="json", exclude_none=True) data = notification.model_dump(by_alias=True, mode="json", exclude_none=True)
await self._dispatcher.notify(data["method"], data.get("params")) opts: CallOptions = {}
self._stamp(data, opts)
await self._dispatcher.notify(data["method"], data.get("params"), opts)
def _build_capabilities(self) -> types.ClientCapabilities: def _build_capabilities(self) -> types.ClientCapabilities:
sampling = ( sampling = (
@ -314,56 +345,180 @@ class ClientSession:
async def initialize(self) -> types.InitializeResult: async def initialize(self) -> types.InitializeResult:
if self._initialize_result is not None: if self._initialize_result is not None:
return self._initialize_result return self._initialize_result
capabilities = self._build_capabilities()
result = await self.send_request( result = await self.send_request(
types.InitializeRequest( types.InitializeRequest(
params=types.InitializeRequestParams( params=types.InitializeRequestParams(
protocol_version=self._pinned_version protocol_version=LATEST_HANDSHAKE_VERSION,
if self._pinned_version is not None capabilities=self._build_capabilities(),
else types.LATEST_PROTOCOL_VERSION,
capabilities=capabilities,
client_info=self._client_info, client_info=self._client_info,
), ),
), ),
types.InitializeResult, types.InitializeResult,
) )
if result.protocol_version not in SUPPORTED_PROTOCOL_VERSIONS: if result.protocol_version not in HANDSHAKE_PROTOCOL_VERSIONS:
raise RuntimeError(f"Unsupported protocol version from the server: {result.protocol_version}") raise RuntimeError(f"Unsupported protocol version from the server: {result.protocol_version}")
self._initialize_result = result self.adopt(result)
await self.send_notification(types.InitializedNotification()) await self.send_notification(types.InitializedNotification())
return result return result
def adopt(self, result: types.InitializeResult | types.DiscoverResult) -> None:
"""Install negotiated state from a result the caller already holds (no wire traffic).
Clears the opposite slot, so at most one of `initialize_result` /
`discover_result` is ever non-None.
Raises:
RuntimeError: `result` is a `DiscoverResult` whose `supported_versions`
shares nothing with this client's `MODERN_PROTOCOL_VERSIONS`.
"""
if isinstance(result, types.DiscoverResult):
# ordered oldest→newest via MODERN_PROTOCOL_VERSIONS
mutual = [v for v in MODERN_PROTOCOL_VERSIONS if v in result.supported_versions]
if not mutual:
raise RuntimeError(
f"No mutually supported modern protocol version "
f"(server: {result.supported_versions}, client: {list(MODERN_PROTOCOL_VERSIONS)})"
)
client_info = self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True)
capabilities = self._build_capabilities().model_dump(by_alias=True, mode="json", exclude_none=True)
self._stamp = _make_modern_stamp(mutual[-1], client_info, capabilities)
self._discover_result = result
self._initialize_result = None
self._negotiated_version = mutual[-1]
else:
self._stamp = _make_handshake_stamp(result.protocol_version)
self._initialize_result = result
self._discover_result = None
self._negotiated_version = result.protocol_version
async def send_discover(self, version: str) -> dict[str, Any]:
"""Send a single ``server/discover`` at ``version`` and return the raw result dict.
No retry, no ``adopt()``. The ``_meta`` envelope and the
``Mcp-Protocol-Version`` header are stamped at ``version`` so the
server-side era router sees a coherent probe. Used by ``discover()`` and
the connect-time auto-negotiation policy.
Raises:
MCPError: The server returned a JSON-RPC error, or the transport
bounced the request at its own layer (a bare HTTP 4xx is
synthesized into a JSON-RPC error by the transport).
"""
client_info = self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True)
capabilities = self._build_capabilities().model_dump(by_alias=True, mode="json", exclude_none=True)
request = types.DiscoverRequest(
params=types.RequestParams(
_meta={
PROTOCOL_VERSION_META_KEY: version,
CLIENT_INFO_META_KEY: client_info,
CLIENT_CAPABILITIES_META_KEY: capabilities,
}
)
)
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
opts: CallOptions = {
"timeout": DISCOVER_TIMEOUT_SECONDS,
"cancel_on_abandon": False,
"headers": {MCP_PROTOCOL_VERSION_HEADER: version},
}
return await self._dispatcher.send_raw_request(data["method"], data.get("params"), opts)
async def discover(self) -> types.DiscoverResult:
"""Probe `server/discover` and adopt the result.
Sends a single `server/discover` proposing the newest modern protocol
version. On `UNSUPPORTED_PROTOCOL_VERSION` (-32022) the server's
`supported` list is intersected with `MODERN_PROTOCOL_VERSIONS` and the
probe is retried once at the highest mutual version. Any other error —
including `METHOD_NOT_FOUND` (-32601) and `REQUEST_TIMEOUT` (-32001) —
propagates; the legacy `initialize()` fallback is the caller's policy.
Raises:
MCPError: The server rejected `server/discover`, the probe timed
out, or the -32022 retry found no mutual version / failed again.
RuntimeError: `adopt()` found no mutual version in the returned
`supported_versions`.
"""
if self._discover_result is not None:
return self._discover_result
try:
raw = await self.send_discover(LATEST_MODERN_VERSION)
except MCPError as e:
if e.code != UNSUPPORTED_PROTOCOL_VERSION:
raise
try:
data = types.UnsupportedProtocolVersionErrorData.model_validate(e.error.data)
except ValidationError:
raise e from None
# ordered oldest→newest via MODERN_PROTOCOL_VERSIONS
mutual = [v for v in MODERN_PROTOCOL_VERSIONS if v in data.supported]
if not mutual:
raise
raw = await self.send_discover(mutual[-1])
result = types.DiscoverResult.model_validate(raw)
self.adopt(result)
return result
@property @property
def initialize_result(self) -> types.InitializeResult | None: def initialize_result(self) -> types.InitializeResult | None:
"""The server's InitializeResult. None until initialize() has been called. """The server's InitializeResult. None unless `initialize()` ran (or was adopted)."""
A stateless-pinned session (protocol_version >= 2026-07-28) is born
initialized: this property is populated at construction with a
synthesized result and `initialize()` returns it without touching the
wire. Contains server_info, capabilities, instructions, and the
negotiated protocol_version.
"""
return self._initialize_result return self._initialize_result
@property @property
def protocol_version(self) -> str | None: def discover_result(self) -> types.DiscoverResult | None:
"""Negotiated or pinned protocol version. None until initialize() unless pinned at construction. """The server's DiscoverResult. None unless `discover()` ran (or was adopted).
Once `initialize()` has completed, this is the version the server actually Retained intact (supported_versions, ttl_ms, cache_scope) so callers
negotiated (which can differ from a stateful pin); before that, the pin. can round-trip it as ``prior_discover=``.
""" """
return self._discover_result
@property
def protocol_version(self) -> str | None:
"""Negotiated protocol version. None until `initialize()`, `discover()`, or `adopt()`."""
return self._negotiated_version
@property
def server_info(self) -> types.Implementation | None:
"""Server name/version. None until `initialize()`, `discover()`, or `adopt()`."""
if self._discover_result is not None:
return self._discover_result.server_info
if self._initialize_result is not None: if self._initialize_result is not None:
return self._initialize_result.protocol_version return self._initialize_result.server_info
return self._pinned_version return None
@property
def server_capabilities(self) -> types.ServerCapabilities | None:
"""Server capabilities. None until `initialize()`, `discover()`, or `adopt()`."""
if self._discover_result is not None:
return self._discover_result.capabilities
if self._initialize_result is not None:
return self._initialize_result.capabilities
return None
@property
def instructions(self) -> str | None:
"""Server-provided instructions text, if any."""
if self._discover_result is not None:
return self._discover_result.instructions
if self._initialize_result is not None:
return self._initialize_result.instructions
return None
async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult: async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult:
"""Send a ping request.""" """Send a ping request."""
return await self.send_request(types.PingRequest(params=types.RequestParams(_meta=meta)), types.EmptyResult) return await self.send_request(types.PingRequest(params=types.RequestParams(_meta=meta)), types.EmptyResult)
@deprecated(
"Client-to-server progress is deprecated as of 2026-07-28; progress is server-to-client only.",
category=MCPDeprecationWarning,
)
async def send_progress_notification( async def send_progress_notification(
self, self,
progress_token: str | int, progress_token: str | int,
@ -563,7 +718,7 @@ class ClientSession:
# Literal, not LATEST_PROTOCOL_VERSION: the fallback covers the initialize # Literal, not LATEST_PROTOCOL_VERSION: the fallback covers the initialize
# handshake (which only exists at <=2025) and stateless until the header # handshake (which only exists at <=2025) and stateless until the header
# is plumbed; its meaning is fixed regardless of LATEST bumps. # is plumbed; its meaning is fixed regardless of LATEST bumps.
version = self.protocol_version or "2025-11-25" version = self._negotiated_version or "2025-11-25"
try: try:
request = cast(types.ServerRequest, _methods.parse_server_request(method, version, params)) request = cast(types.ServerRequest, _methods.parse_server_request(method, version, params))
except KeyError: except KeyError:
@ -601,7 +756,7 @@ class ClientSession:
) -> None: ) -> None:
"""Route a server notification: validate, run the typed callback, tee to message_handler.""" """Route a server notification: validate, run the typed callback, tee to message_handler."""
# Same fallback as `_on_request`: covers pre-handshake and stateless. # Same fallback as `_on_request`: covers pre-handshake and stateless.
version = self.protocol_version or "2025-11-25" version = self._negotiated_version or "2025-11-25"
try: try:
notification = cast(types.ServerNotification, _methods.parse_server_notification(method, version, params)) notification = cast(types.ServerNotification, _methods.parse_server_notification(method, version, params))
except KeyError: except KeyError:

View file

@ -2,10 +2,8 @@
from __future__ import annotations as _annotations from __future__ import annotations as _annotations
import base64
import contextlib import contextlib
import logging import logging
import re
from collections.abc import AsyncGenerator, Awaitable, Callable from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dataclasses import dataclass from dataclasses import dataclass
@ -20,14 +18,14 @@ from mcp.client._transport import TransportStreams
from mcp.shared._compat import resync_tracer from mcp.shared._compat import resync_tracer
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
from mcp.shared._httpx_utils import create_mcp_http_client from mcp.shared._httpx_utils import create_mcp_http_client
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
from mcp.shared.message import ClientMessageMetadata, SessionMessage from mcp.shared.message import ClientMessageMetadata, SessionMessage
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
from mcp.types import ( from mcp.types import (
INTERNAL_ERROR, INTERNAL_ERROR,
INVALID_REQUEST, INVALID_REQUEST,
METHOD_NOT_FOUND,
PARSE_ERROR, PARSE_ERROR,
ErrorData, ErrorData,
InitializeResult,
JSONRPCError, JSONRPCError,
JSONRPCMessage, JSONRPCMessage,
JSONRPCNotification, JSONRPCNotification,
@ -46,25 +44,12 @@ StreamWriter = ContextSendStream[SessionMessageOrError]
StreamReader = ContextReceiveStream[SessionMessage] StreamReader = ContextReceiveStream[SessionMessage]
MCP_SESSION_ID = "mcp-session-id" MCP_SESSION_ID = "mcp-session-id"
MCP_PROTOCOL_VERSION = "mcp-protocol-version"
MCP_METHOD = "mcp-method"
MCP_NAME = "mcp-name"
LAST_EVENT_ID = "last-event-id" LAST_EVENT_ID = "last-event-id"
# Reconnection defaults # Reconnection defaults
DEFAULT_RECONNECTION_DELAY_MS = 1000 # 1 second fallback when server doesn't provide retry DEFAULT_RECONNECTION_DELAY_MS = 1000 # 1 second fallback when server doesn't provide retry
MAX_RECONNECTION_ATTEMPTS = 2 # Max retry attempts before giving up MAX_RECONNECTION_ATTEMPTS = 2 # Max retry attempts before giving up
_B64_SENTINEL = re.compile(r"^=\?base64\?.*\?=$")
# RFC 7230 token chars minus DEL; visible ASCII 0x20-0x7E is the practical bound for a header value.
_HEADER_SAFE = re.compile(r"^[\x20-\x7E]*$")
def _encode_header_value(value: str) -> str:
if _HEADER_SAFE.fullmatch(value) and value == value.strip() and not _B64_SENTINEL.fullmatch(value):
return value
return f"=?base64?{base64.b64encode(value.encode('utf-8')).decode('ascii')}?="
class StreamableHTTPError(Exception): class StreamableHTTPError(Exception):
"""Base exception for StreamableHTTP transport errors.""" """Base exception for StreamableHTTP transport errors."""
@ -88,57 +73,38 @@ class RequestContext:
class StreamableHTTPTransport: class StreamableHTTPTransport:
"""StreamableHTTP client transport implementation.""" """StreamableHTTP client transport implementation."""
def __init__(self, url: str, protocol_version: str | None = None) -> None: def __init__(self, url: str) -> None:
"""Initialize the StreamableHTTP transport. """Initialize the StreamableHTTP transport.
Args: Args:
url: The endpoint URL. url: The endpoint URL.
protocol_version: Pin the MCP-Protocol-Version header from the first request.
Only honoured for stateless 2026-07-28+ sessions that never send
initialize; for earlier (stateful) versions the header is populated
from the negotiated InitializeResult, so a pre-2026 value is ignored.
""" """
self.url = url self.url = url
self.session_id: str | None = None self.session_id: str | None = None
self.protocol_version: str | None = protocol_version if protocol_version in MODERN_PROTOCOL_VERSIONS else None # Captured from each stamped POST's metadata. Reused on outbound HTTP that carries
# no per-message header (transport-internal GET/DELETE, and dispatcher-written
def _per_message_headers(self, message: JSONRPCMessage) -> dict[str, str]: # response/error/cancel POSTs that bypass the session's stamp). Cleared when an
"""Per-POST routing headers (Mcp-Method, Mcp-Name) for 2026-07-28+ pinned transports. # `initialize` POST goes out so a probe-stamped value cannot leak onto the handshake.
self._protocol_version_header: str | None = None
MCP-Protocol-Version is not emitted here — `_prepare_headers()` already adds it
from `self.protocol_version` for every request.
"""
if self.protocol_version not in MODERN_PROTOCOL_VERSIONS:
return {}
if not isinstance(message, JSONRPCRequest | JSONRPCNotification):
return {}
headers: dict[str, str] = {MCP_METHOD: message.method}
# TODO: Mcp-Name is also REQUIRED for prompts/get (params.name) and resources/read
# (params.uri); a method->param-key map replaces this gate when those land.
if (
isinstance(message, JSONRPCRequest)
and message.method == "tools/call"
and message.params
and isinstance(name := message.params.get("name"), str)
):
headers[MCP_NAME] = _encode_header_value(name)
return headers
def _prepare_headers(self) -> dict[str, str]: def _prepare_headers(self) -> dict[str, str]:
"""Build MCP-specific request headers. """Build MCP-specific request headers for any outbound HTTP request.
These headers will be merged with the httpx.AsyncClient's default headers, These are merged with the ``httpx.AsyncClient`` defaults (these take
with these MCP-specific headers taking precedence. precedence). The cached ``MCP-Protocol-Version`` is included whenever
present so messages that don't pass through the session's stamp —
response/error/cancel POSTs, transport-internal GET/DELETE — still
carry the negotiated version. Per-message headers are layered on top
by the caller.
""" """
headers: dict[str, str] = { headers: dict[str, str] = {
"accept": "application/json, text/event-stream", "accept": "application/json, text/event-stream",
"content-type": "application/json", "content-type": "application/json",
} }
# Add session headers if available
if self.session_id: if self.session_id:
headers[MCP_SESSION_ID] = self.session_id headers[MCP_SESSION_ID] = self.session_id
if self.protocol_version: if self._protocol_version_header:
headers[MCP_PROTOCOL_VERSION] = self.protocol_version headers[MCP_PROTOCOL_VERSION_HEADER] = self._protocol_version_header
return headers return headers
def _is_initialization_request(self, message: JSONRPCMessage) -> bool: def _is_initialization_request(self, message: JSONRPCMessage) -> bool:
@ -156,29 +122,12 @@ class StreamableHTTPTransport:
self.session_id = new_session_id self.session_id = new_session_id
logger.info(f"Received session ID: {self.session_id}") logger.info(f"Received session ID: {self.session_id}")
def _maybe_extract_protocol_version_from_message(self, message: JSONRPCMessage) -> None:
"""Extract protocol version from initialization response message."""
if self.protocol_version is not None:
# Only a modern constructor pin reaches here (pre-2026 values are dropped
# in __init__), and a modern pin never sends initialize.
return
if isinstance(message, JSONRPCResponse) and message.result: # pragma: no branch
try:
# Parse the result as InitializeResult for type safety
init_result = InitializeResult.model_validate(message.result, by_name=False)
self.protocol_version = init_result.protocol_version
logger.info(f"Negotiated protocol version: {self.protocol_version}")
except Exception: # pragma: no cover
logger.warning("Failed to parse initialization response as InitializeResult", exc_info=True)
logger.warning(f"Raw result: {message.result}")
async def _handle_sse_event( async def _handle_sse_event(
self, self,
sse: ServerSentEvent, sse: ServerSentEvent,
read_stream_writer: StreamWriter, read_stream_writer: StreamWriter,
original_request_id: RequestId | None = None, original_request_id: RequestId | None = None,
resumption_callback: Callable[[str], Awaitable[None]] | None = None, resumption_callback: Callable[[str], Awaitable[None]] | None = None,
is_initialization: bool = False,
) -> bool: ) -> bool:
"""Handle an SSE event, returning True if the response is complete.""" """Handle an SSE event, returning True if the response is complete."""
if sse.event == "message": if sse.event == "message":
@ -192,10 +141,6 @@ class StreamableHTTPTransport:
message = jsonrpc_message_adapter.validate_json(sse.data, by_name=False) message = jsonrpc_message_adapter.validate_json(sse.data, by_name=False)
logger.debug(f"SSE message: {message}") logger.debug(f"SSE message: {message}")
# Extract protocol version from initialization response
if is_initialization:
self._maybe_extract_protocol_version_from_message(message)
# If this is a response and we have original_request_id, replace it # If this is a response and we have original_request_id, replace it
if original_request_id is not None and isinstance(message, JSONRPCResponse | JSONRPCError): if original_request_id is not None and isinstance(message, JSONRPCResponse | JSONRPCError):
message.id = original_request_id message.id = original_request_id
@ -299,10 +244,17 @@ class StreamableHTTPTransport:
async def _handle_post_request(self, ctx: RequestContext) -> None: async def _handle_post_request(self, ctx: RequestContext) -> None:
"""Handle a POST request with response processing.""" """Handle a POST request with response processing."""
headers = self._prepare_headers()
message = ctx.session_message.message message = ctx.session_message.message
headers.update(self._per_message_headers(message))
is_initialization = self._is_initialization_request(message) is_initialization = self._is_initialization_request(message)
if is_initialization:
# `initialize` is the negotiation, not a "subsequent request" — discard any
# probe-stamped value so the discover→fallback path can't leak it onto the handshake.
self._protocol_version_header = None
headers = self._prepare_headers()
if ctx.metadata is not None and ctx.metadata.headers is not None:
headers.update(ctx.metadata.headers)
if MCP_PROTOCOL_VERSION_HEADER in ctx.metadata.headers:
self._protocol_version_header = ctx.metadata.headers[MCP_PROTOCOL_VERSION_HEADER]
async with ctx.client.stream( async with ctx.client.stream(
"POST", "POST",
@ -334,7 +286,13 @@ class StreamableHTTPTransport:
pass pass
logger.debug("Non-2xx body was not a JSON-RPC error; using fallback") logger.debug("Non-2xx body was not a JSON-RPC error; using fallback")
if response.status_code == 404: if response.status_code == 404:
error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated") if self.session_id is None:
# No session yet → 404 is the HTTP-level spelling of
# METHOD_NOT_FOUND (gateway / legacy server doesn't know
# this method); "Session terminated" would be a lie here.
error_data = ErrorData(code=METHOD_NOT_FOUND, message="Not Found")
else:
error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated")
else: else:
error_data = ErrorData(code=INTERNAL_ERROR, message="Server returned an error response") error_data = ErrorData(code=INTERNAL_ERROR, message="Server returned an error response")
session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data)) session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data))
@ -349,11 +307,9 @@ class StreamableHTTPTransport:
if isinstance(message, JSONRPCRequest): if isinstance(message, JSONRPCRequest):
content_type = response.headers.get("content-type", "").lower() content_type = response.headers.get("content-type", "").lower()
if content_type.startswith("application/json"): if content_type.startswith("application/json"):
await self._handle_json_response( await self._handle_json_response(response, ctx.read_stream_writer, request_id=message.id)
response, ctx.read_stream_writer, is_initialization, request_id=message.id
)
elif content_type.startswith("text/event-stream"): elif content_type.startswith("text/event-stream"):
await self._handle_sse_response(response, ctx, is_initialization) await self._handle_sse_response(response, ctx)
else: else:
logger.error(f"Unexpected content type: {content_type}") logger.error(f"Unexpected content type: {content_type}")
error_data = ErrorData(code=INVALID_REQUEST, message=f"Unexpected content type: {content_type}") error_data = ErrorData(code=INVALID_REQUEST, message=f"Unexpected content type: {content_type}")
@ -364,7 +320,6 @@ class StreamableHTTPTransport:
self, self,
response: httpx.Response, response: httpx.Response,
read_stream_writer: StreamWriter, read_stream_writer: StreamWriter,
is_initialization: bool = False,
*, *,
request_id: RequestId, request_id: RequestId,
) -> None: ) -> None:
@ -372,11 +327,6 @@ class StreamableHTTPTransport:
try: try:
content = await response.aread() content = await response.aread()
message = jsonrpc_message_adapter.validate_json(content, by_name=False) message = jsonrpc_message_adapter.validate_json(content, by_name=False)
# Extract protocol version from initialization response
if is_initialization:
self._maybe_extract_protocol_version_from_message(message)
session_message = SessionMessage(message) session_message = SessionMessage(message)
await read_stream_writer.send(session_message) await read_stream_writer.send(session_message)
except (httpx.StreamError, ValidationError) as exc: except (httpx.StreamError, ValidationError) as exc:
@ -389,7 +339,6 @@ class StreamableHTTPTransport:
self, self,
response: httpx.Response, response: httpx.Response,
ctx: RequestContext, ctx: RequestContext,
is_initialization: bool = False,
) -> None: ) -> None:
"""Handle SSE response from the server.""" """Handle SSE response from the server."""
last_event_id: str | None = None last_event_id: str | None = None
@ -416,7 +365,6 @@ class StreamableHTTPTransport:
ctx.read_stream_writer, ctx.read_stream_writer,
original_request_id=original_request_id, original_request_id=original_request_id,
resumption_callback=(ctx.metadata.on_resumption_token_update if ctx.metadata else None), resumption_callback=(ctx.metadata.on_resumption_token_update if ctx.metadata else None),
is_initialization=is_initialization,
) )
# If the SSE event indicates completion, like returning response/error # If the SSE event indicates completion, like returning response/error
# break the loop # break the loop
@ -581,7 +529,6 @@ async def streamable_http_client(
*, *,
http_client: httpx.AsyncClient | None = None, http_client: httpx.AsyncClient | None = None,
terminate_on_close: bool = True, terminate_on_close: bool = True,
protocol_version: str | None = None,
) -> AsyncGenerator[TransportStreams, None]: ) -> AsyncGenerator[TransportStreams, None]:
"""Client transport for StreamableHTTP. """Client transport for StreamableHTTP.
@ -591,8 +538,6 @@ async def streamable_http_client(
client with recommended MCP timeouts will be created. To configure headers, client with recommended MCP timeouts will be created. To configure headers,
authentication, or other HTTP settings, create an httpx.AsyncClient and pass it here. authentication, or other HTTP settings, create an httpx.AsyncClient and pass it here.
terminate_on_close: If True, send a DELETE request to terminate the session when the context exits. terminate_on_close: If True, send a DELETE request to terminate the session when the context exits.
protocol_version: Pin the MCP-Protocol-Version header for stateless 2026-07-28 sessions.
Tracer-bullet duplication — also pass to `ClientSession(protocol_version=...)`.
Yields: Yields:
Tuple containing: Tuple containing:
@ -610,7 +555,7 @@ async def streamable_http_client(
# Create default client with recommended MCP timeouts # Create default client with recommended MCP timeouts
client = create_mcp_http_client() client = create_mcp_http_client()
transport = StreamableHTTPTransport(url, protocol_version=protocol_version) transport = StreamableHTTPTransport(url)
logger.debug(f"Connecting to StreamableHTTP endpoint: {url}") logger.debug(f"Connecting to StreamableHTTP endpoint: {url}")

View file

@ -14,7 +14,7 @@ from __future__ import annotations
import json import json
import logging import logging
from collections.abc import Mapping from collections.abc import Awaitable, Mapping
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, TypeVar from typing import TYPE_CHECKING, Any, TypeVar
@ -30,9 +30,11 @@ from mcp.server.transport_security import TransportSecurityMiddleware, Transport
from mcp.shared.dispatcher import CallOptions from mcp.shared.dispatcher import CallOptions
from mcp.shared.exceptions import NoBackChannelError from mcp.shared.exceptions import NoBackChannelError
from mcp.shared.inbound import ERROR_CODE_HTTP_STATUS, InboundLadderRejection, classify_inbound_request from mcp.shared.inbound import ERROR_CODE_HTTP_STATUS, InboundLadderRejection, classify_inbound_request
from mcp.shared.jsonrpc_dispatcher import handler_exception_to_error_data
from mcp.shared.message import MessageMetadata, ServerMessageMetadata from mcp.shared.message import MessageMetadata, ServerMessageMetadata
from mcp.shared.transport_context import TransportContext from mcp.shared.transport_context import TransportContext
from mcp.types import ( from mcp.types import (
INTERNAL_ERROR,
INVALID_REQUEST, INVALID_REQUEST,
PARSE_ERROR, PARSE_ERROR,
ClientCapabilities, ClientCapabilities,
@ -77,7 +79,7 @@ class _SingleExchangeDispatchContext:
) -> dict[str, Any]: ) -> dict[str, Any]:
raise NoBackChannelError(method) raise NoBackChannelError(method)
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
# TODO(D-005a): buffer and stream as SSE once the JSON-vs-SSE response mode lands. # TODO(D-005a): buffer and stream as SSE once the JSON-vs-SSE response mode lands.
return None return None
@ -99,6 +101,27 @@ def _typed(model: type[_ModelT], raw: Any) -> _ModelT | None:
return None return None
async def _to_jsonrpc_response(
request_id: RequestId, coro: Awaitable[dict[str, Any]]
) -> JSONRPCResponse | JSONRPCError:
"""Await ``coro`` and wrap its outcome as the JSON-RPC reply for ``request_id``.
The exception-to-wire boundary for the modern HTTP entry, composed around
`serve_one`. `MCPError` and `ValidationError` map via the shared
`handler_exception_to_error_data` ladder; any other exception is logged and
surfaced as `INTERNAL_ERROR` so handler internals never reach the wire.
"""
try:
result = await coro
except Exception as exc:
error = handler_exception_to_error_data(exc)
if error is None:
logger.exception("request handler raised")
error = ErrorData(code=INTERNAL_ERROR, message="Internal server error")
return JSONRPCError(jsonrpc="2.0", id=request_id, error=error)
return JSONRPCResponse(jsonrpc="2.0", id=request_id, result=result)
async def _write( async def _write(
msg: JSONRPCResponse | JSONRPCError, msg: JSONRPCResponse | JSONRPCError,
scope: Scope, scope: Scope,
@ -193,5 +216,7 @@ async def handle_modern_request(
request_id=req.id, request_id=req.id,
message_metadata=ServerMessageMetadata(request_context=request), message_metadata=ServerMessageMetadata(request_context=request),
) )
msg = await serve_one(app, req, connection=connection, dctx=dctx, lifespan_state=lifespan_state) msg = await _to_jsonrpc_response(
req.id, serve_one(app, dctx, req.method, req.params, connection=connection, lifespan_state=lifespan_state)
)
await _write(msg, scope, receive, send) await _write(msg, scope, receive, send)

View file

@ -17,8 +17,8 @@ from mcp.server.auth.handlers.token import TokenHandler
from mcp.server.auth.middleware.client_auth import ClientAuthenticator from mcp.server.auth.middleware.client_auth import ClientAuthenticator
from mcp.server.auth.provider import OAuthAuthorizationServerProvider from mcp.server.auth.provider import OAuthAuthorizationServerProvider
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
from mcp.server.streamable_http import MCP_PROTOCOL_VERSION_HEADER
from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
def validate_issuer_url(url: AnyHttpUrl): def validate_issuer_url(url: AnyHttpUrl):

View file

@ -31,8 +31,8 @@ from typing_extensions import deprecated
from mcp.shared.dispatcher import CallOptions, Outbound from mcp.shared.dispatcher import CallOptions, Outbound
from mcp.shared.exceptions import MCPDeprecationWarning, NoBackChannelError from mcp.shared.exceptions import MCPDeprecationWarning, NoBackChannelError
from mcp.shared.peer import Meta, dump_params from mcp.shared.peer import Meta, dump_params
from mcp.shared.version import LATEST_HANDSHAKE_VERSION
from mcp.types import ( from mcp.types import (
LATEST_PROTOCOL_VERSION,
ClientCapabilities, ClientCapabilities,
CreateMessageRequest, CreateMessageRequest,
CreateMessageResult, CreateMessageResult,
@ -93,7 +93,7 @@ class _NoChannelOutbound:
) -> dict[str, Any]: ) -> dict[str, Any]:
raise NoBackChannelError(method) raise NoBackChannelError(method)
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
logger.debug("dropped %s: no standalone channel", method) logger.debug("dropped %s: no standalone channel", method)
@ -192,12 +192,12 @@ class Connection:
Not born-ready: `initialized` is set later by the kernel when Not born-ready: `initialized` is set later by the kernel when
`notifications/initialized` arrives. `protocol_version` is seeded from `notifications/initialized` arrives. `protocol_version` is seeded from
the transport hint (or `LATEST_PROTOCOL_VERSION`) so it's never `None`; the transport hint (or `LATEST_HANDSHAKE_VERSION`) so it's never `None`;
the handshake overwrites it once negotiated. the handshake overwrites it once negotiated.
""" """
return cls( return cls(
outbound, outbound,
protocol_version=protocol_version_hint if protocol_version_hint is not None else LATEST_PROTOCOL_VERSION, protocol_version=protocol_version_hint if protocol_version_hint is not None else LATEST_HANDSHAKE_VERSION,
session_id=session_id, session_id=session_id,
) )
@ -275,14 +275,14 @@ class Connection:
cls = result_type if result_type is not None else _RESULT_FOR[type(req)] cls = result_type if result_type is not None else _RESULT_FOR[type(req)]
return cls.model_validate(raw, by_name=False) return cls.model_validate(raw, by_name=False)
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
"""Send a best-effort notification on the standalone stream. """Send a best-effort notification on the standalone stream.
Never raises. If there's no standalone channel or the stream is broken, Never raises. If there's no standalone channel or the stream is broken,
the notification is dropped and debug-logged. the notification is dropped and debug-logged.
""" """
try: try:
await self.outbound.notify(method, params) await self.outbound.notify(method, params, opts)
except (anyio.BrokenResourceError, anyio.ClosedResourceError): except (anyio.BrokenResourceError, anyio.ClosedResourceError):
logger.debug("dropped %s: standalone stream closed", method) logger.debug("dropped %s: standalone stream closed", method)

View file

@ -94,18 +94,7 @@ class Context(BaseModel, Generic[LifespanContextT, RequestT]):
total: Optional total value (e.g., 100) total: Optional total value (e.g., 100)
message: Optional message (e.g., "Starting render...") message: Optional message (e.g., "Starting render...")
""" """
progress_token = self.request_context.meta.get("progress_token") if self.request_context.meta else None await self.request_context.session.report_progress(progress, total, message)
if progress_token is None:
return
await self.request_context.session.send_progress_notification(
progress_token=progress_token,
progress=progress,
total=total,
message=message,
related_request_id=self.request_id,
)
async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]: async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]:
"""Read a resource by URI. """Read a resource by URI.

View file

@ -33,23 +33,21 @@ from mcp.shared._otel import extract_trace_context, otel_span
from mcp.shared._stream_protocols import ReadStream, WriteStream from mcp.shared._stream_protocols import ReadStream, WriteStream
from mcp.shared.dispatcher import DispatchContext, Dispatcher, DispatchMiddleware, OnNotify, OnRequest from mcp.shared.dispatcher import DispatchContext, Dispatcher, DispatchMiddleware, OnNotify, OnRequest
from mcp.shared.exceptions import MCPError from mcp.shared.exceptions import MCPError
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher, handler_exception_to_error_data from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.message import ServerMessageMetadata, SessionMessage from mcp.shared.message import ServerMessageMetadata, SessionMessage
from mcp.shared.transport_context import TransportContext from mcp.shared.transport_context import TransportContext
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS from mcp.shared.version import HANDSHAKE_PROTOCOL_VERSIONS, LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
from mcp.types import ( from mcp.types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
INTERNAL_ERROR, INTERNAL_ERROR,
INVALID_PARAMS, INVALID_PARAMS,
LATEST_PROTOCOL_VERSION,
METHOD_NOT_FOUND, METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
ErrorData, ErrorData,
Implementation, Implementation,
InitializeRequestParams, InitializeRequestParams,
InitializeResult, InitializeResult,
JSONRPCError,
JSONRPCRequest,
JSONRPCResponse,
RequestId,
RequestParams, RequestParams,
RequestParamsMeta, RequestParamsMeta,
) )
@ -63,11 +61,11 @@ __all__ = [
"ServerMiddleware", "ServerMiddleware",
"ServerRunner", "ServerRunner",
"aclose_shielded", "aclose_shielded",
"modern_on_request",
"otel_middleware", "otel_middleware",
"serve_connection", "serve_connection",
"serve_loop", "serve_loop",
"serve_one", "serve_one",
"to_jsonrpc_response",
] ]
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -180,26 +178,6 @@ async def aclose_shielded(connection: Connection) -> None:
) )
async def to_jsonrpc_response(request_id: RequestId, coro: Awaitable[dict[str, Any]]) -> JSONRPCResponse | JSONRPCError:
"""Await ``coro`` and wrap its outcome as the JSON-RPC reply for ``request_id``.
The exception-to-wire boundary for the request-per-call drivers
(`serve_one`, the modern HTTP entry). `MCPError` and `ValidationError`
map via the shared `handler_exception_to_error_data` ladder; any other
exception is logged and surfaced as `INTERNAL_ERROR` so handler internals
never reach the wire.
"""
try:
result = await coro
except Exception as exc:
error = handler_exception_to_error_data(exc)
if error is None:
logger.exception("request handler raised")
error = ErrorData(code=INTERNAL_ERROR, message="Internal server error")
return JSONRPCError(jsonrpc="2.0", id=request_id, error=error)
return JSONRPCResponse(jsonrpc="2.0", id=request_id, result=result)
def _apply_middleware( def _apply_middleware(
middleware: ServerMiddleware[Any], call_next: CallNext, ctx: ServerRequestContext[Any, Any] middleware: ServerMiddleware[Any], call_next: CallNext, ctx: ServerRequestContext[Any, Any]
) -> Awaitable[HandlerResult]: ) -> Awaitable[HandlerResult]:
@ -414,7 +392,7 @@ class ServerRunner(Generic[LifespanT]):
"""Validate `initialize` params and pick the protocol version.""" """Validate `initialize` params and pick the protocol version."""
init = InitializeRequestParams.model_validate(params or {}, by_name=False) init = InitializeRequestParams.model_validate(params or {}, by_name=False)
requested = init.protocol_version requested = init.protocol_version
negotiated = requested if requested in SUPPORTED_PROTOCOL_VERSIONS else LATEST_PROTOCOL_VERSION negotiated = requested if requested in HANDSHAKE_PROTOCOL_VERSIONS else LATEST_HANDSHAKE_VERSION
return init, negotiated return init, negotiated
def _handle_initialize(self, params: Mapping[str, Any] | None) -> InitializeResult: def _handle_initialize(self, params: Mapping[str, Any] | None) -> InitializeResult:
@ -494,22 +472,49 @@ async def serve_loop(
async def serve_one( async def serve_one(
server: Server[LifespanT], server: Server[LifespanT],
request: JSONRPCRequest, dctx: DispatchContext[TransportContext],
method: str,
params: Mapping[str, Any] | None,
*, *,
connection: Connection, connection: Connection,
dctx: DispatchContext[TransportContext],
lifespan_state: LifespanT, lifespan_state: LifespanT,
) -> JSONRPCResponse | JSONRPCError: ) -> dict[str, Any]:
"""Handle a single ``request`` and return its JSON-RPC reply. """Handle a single request ``(method, params)`` and return its result dict.
The single-exchange driver: builds the kernel, runs `on_request` once for The single-exchange driver: builds the kernel, runs `on_request` once under
`request` under `dctx`, maps the outcome to a `JSONRPCResponse` / `dctx`, and tears down `connection.exit_stack` (shielded) on the way out.
`JSONRPCError` via `to_jsonrpc_response`, and tears down The entry constructs the (born-ready) `Connection` and the `dctx`; this
`connection.exit_stack` (shielded) on the way out. The entry constructs only consumes them.
the (born-ready) `Connection` and the `dctx`; this only consumes them.
Raises whatever the handler chain raises (`MCPError` / `ValidationError` /
unmapped); callers own the exception-to-wire mapping.
""" """
runner = ServerRunner(server, connection, lifespan_state) runner = ServerRunner(server, connection, lifespan_state)
try: try:
return await to_jsonrpc_response(request.id, runner.on_request(dctx, request.method, request.params)) return await runner.on_request(dctx, method, params)
finally: finally:
await aclose_shielded(connection) await aclose_shielded(connection)
def modern_on_request(server: Server[LifespanT], lifespan_state: LifespanT) -> OnRequest:
"""Return an `OnRequest` callback that serves each call via `serve_one` with a fresh per-request `Connection`.
Wire this into the server side of a `DirectDispatcher` peer-pair to drive an
in-process server on the modern per-request-envelope path (each request
carries protocol version, client info, and capabilities in `params._meta`;
no `initialize` handshake). Like `serve_one`, this raises whatever the
handler chain raises - the dispatcher owns the exception-to-error mapping.
"""
async def handle(
dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
) -> dict[str, Any]:
meta = (params or {}).get("_meta", {})
connection = Connection.from_envelope(
meta.get(PROTOCOL_VERSION_META_KEY, LATEST_MODERN_VERSION),
meta.get(CLIENT_INFO_META_KEY),
meta.get(CLIENT_CAPABILITIES_META_KEY),
)
return await serve_one(server, dctx, method, params, connection=connection, lifespan_state=lifespan_state)
return handle

View file

@ -14,7 +14,7 @@ from typing_extensions import deprecated
from mcp import types from mcp import types
from mcp.server.connection import Connection from mcp.server.connection import Connection
from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages
from mcp.shared.dispatcher import CallOptions, Outbound, ProgressFnT from mcp.shared.dispatcher import CallOptions, DispatchContext, ProgressFnT
from mcp.shared.exceptions import MCPDeprecationWarning from mcp.shared.exceptions import MCPDeprecationWarning
from mcp.shared.message import ServerMessageMetadata from mcp.shared.message import ServerMessageMetadata
from mcp.types import methods as _methods from mcp.types import methods as _methods
@ -36,7 +36,7 @@ class ServerSession:
never crosses the `Outbound` Protocol. never crosses the `Outbound` Protocol.
""" """
def __init__(self, request_outbound: Outbound, connection: Connection) -> None: def __init__(self, request_outbound: DispatchContext[Any], connection: Connection) -> None:
self._request_outbound = request_outbound self._request_outbound = request_outbound
self._connection = connection self._connection = connection
@ -353,6 +353,16 @@ class ServerSession:
types.EmptyResult, types.EmptyResult,
) )
async def report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
"""Report progress for the inbound request this session is scoped to.
A no-op when the caller did not request progress. Dispatcher-agnostic:
on JSON-RPC the held `DispatchContext` emits ``notifications/progress``
against the caller's token; on the in-process direct dispatcher it
invokes the caller's callback directly.
"""
await self._request_outbound.progress(progress, total, message)
async def send_progress_notification( async def send_progress_notification(
self, self,
progress_token: str | int, progress_token: str | int,

View file

@ -28,6 +28,7 @@ from starlette.types import Receive, Scope, Send
from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
from mcp.shared._stream_protocols import ReadStream, WriteStream from mcp.shared._stream_protocols import ReadStream, WriteStream
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
from mcp.shared.message import ServerMessageMetadata, SessionMessage from mcp.shared.message import ServerMessageMetadata, SessionMessage
from mcp.shared.version import is_version_at_least from mcp.shared.version import is_version_at_least
from mcp.types import ( from mcp.types import (
@ -50,7 +51,6 @@ logger = logging.getLogger(__name__)
# Header names # Header names
MCP_SESSION_ID_HEADER = "mcp-session-id" MCP_SESSION_ID_HEADER = "mcp-session-id"
MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version"
LAST_EVENT_ID_HEADER = "last-event-id" LAST_EVENT_ID_HEADER = "last-event-id"
# Content types # Content types
@ -818,7 +818,7 @@ class StreamableHTTPServerTransport:
async def _validate_request_headers(self, request: Request, send: Send) -> bool: async def _validate_request_headers(self, request: Request, send: Send) -> bool:
# Protocol-version validation lives in the manager's era-routing: only # Protocol-version validation lives in the manager's era-routing: only
# values in `SUPPORTED_PROTOCOL_VERSIONS` (or no header at all) reach # values in `HANDSHAKE_PROTOCOL_VERSIONS` (or no header at all) reach
# this transport, so the legacy version-gate is gone. # this transport, so the legacy version-gate is gone.
return await self._validate_session(request, send) return await self._validate_session(request, send)

View file

@ -19,16 +19,16 @@ from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, Authorizat
from mcp.server.connection import Connection from mcp.server.connection import Connection
from mcp.server.runner import serve_connection, serve_loop from mcp.server.runner import serve_connection, serve_loop
from mcp.server.streamable_http import ( from mcp.server.streamable_http import (
MCP_PROTOCOL_VERSION_HEADER,
MCP_SESSION_ID_HEADER, MCP_SESSION_ID_HEADER,
EventStore, EventStore,
StreamableHTTPServerTransport, StreamableHTTPServerTransport,
) )
from mcp.server.transport_security import TransportSecuritySettings from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared._compat import resync_tracer from mcp.shared._compat import resync_tracer
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.transport_context import TransportContext from mcp.shared.transport_context import TransportContext
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS from mcp.shared.version import HANDSHAKE_PROTOCOL_VERSIONS
from mcp.types import DEFAULT_NEGOTIATED_VERSION, INVALID_REQUEST, ErrorData, JSONRPCError from mcp.types import DEFAULT_NEGOTIATED_VERSION, INVALID_REQUEST, ErrorData, JSONRPCError
if TYPE_CHECKING: if TYPE_CHECKING:
@ -169,7 +169,7 @@ class StreamableHTTPSessionManager:
# and return a structured rejection. 2025 paths below remain unchanged. # and return a structured rejection. 2025 paths below remain unchanged.
header = MCP_PROTOCOL_VERSION_HEADER.encode("ascii") header = MCP_PROTOCOL_VERSION_HEADER.encode("ascii")
pv = next((v.decode("latin-1") for k, v in scope["headers"] if k == header), None) pv = next((v.decode("latin-1") for k, v in scope["headers"] if k == header), None)
if pv is not None and pv not in SUPPORTED_PROTOCOL_VERSIONS: if pv is not None and pv not in HANDSHAKE_PROTOCOL_VERSIONS:
await handle_modern_request(self.app, self.security_settings, self._lifespan_state, scope, receive, send) await handle_modern_request(self.app, self.security_settings, self._lifespan_state, scope, receive, send)
return return

Some files were not shown because too many files have changed in this diff Show more