Client-side 2026-07-28 support: .discover()/.adopt() + Client(mode=); request-metadata green (#2950)
This commit is contained in:
parent
ae13ede143
commit
f226d00d0a
84 changed files with 2533 additions and 993 deletions
201
.github/actions/conformance/client.py
vendored
201
.github/actions/conformance/client.py
vendored
|
|
@ -17,6 +17,9 @@ Scenarios:
|
|||
initialize - Connect, initialize, list tools, close
|
||||
tools_call - Connect, call add_numbers(a=5, b=3), 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
|
||||
auth/client-credentials-jwt - Client credentials with private_key_jwt
|
||||
auth/client-credentials-basic - Client credentials with client_secret_basic
|
||||
|
|
@ -35,16 +38,18 @@ from urllib.parse import parse_qs, urlparse
|
|||
import httpx
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from mcp import ClientSession, types
|
||||
from mcp import types
|
||||
from mcp.client.auth import OAuthClientProvider, TokenStorage
|
||||
from mcp.client.auth.extensions.client_credentials import (
|
||||
ClientCredentialsOAuthProvider,
|
||||
PrivateKeyJWTOAuthProvider,
|
||||
SignedJWTParameters,
|
||||
)
|
||||
from mcp.client.client import Client
|
||||
from mcp.client.context import ClientRequestContext
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
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)
|
||||
logging.basicConfig(
|
||||
|
|
@ -58,10 +63,24 @@ logger = logging.getLogger(__name__)
|
|||
#: "2026-07-28"). The harness always sets this (when --spec-version is omitted
|
||||
#: it picks per-scenario: LATEST_SPEC_VERSION for active scenarios,
|
||||
#: 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
|
||||
#: branch on this once the SDK has one; today it is logged only.
|
||||
#: outside the harness.
|
||||
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
|
||||
ScenarioHandler = Callable[[str], Coroutine[Any, None, None]]
|
||||
|
||||
|
|
@ -165,52 +184,22 @@ class ConformanceOAuthCallbackHandler:
|
|||
return result
|
||||
|
||||
|
||||
# --- Scenario Handlers ---
|
||||
# --- Stub callbacks (declare capabilities in _meta without doing real work) ---
|
||||
|
||||
|
||||
@register("initialize")
|
||||
async def run_initialize(server_url: str) -> None:
|
||||
"""Connect, initialize, list tools, 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()
|
||||
logger.debug("Initialized successfully")
|
||||
await session.list_tools()
|
||||
logger.debug("Listed tools successfully")
|
||||
async def stub_sampling_callback(
|
||||
context: ClientRequestContext,
|
||||
params: types.CreateMessageRequestParams,
|
||||
) -> types.CreateMessageResult | types.ErrorData:
|
||||
return types.CreateMessageResult(
|
||||
role="assistant",
|
||||
content=types.TextContent(type="text", text=""),
|
||||
model="conformance-stub",
|
||||
)
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
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 stub_list_roots_callback(context: ClientRequestContext) -> types.ListRootsResult | types.ErrorData:
|
||||
return types.ListRootsResult(roots=[])
|
||||
|
||||
|
||||
async def default_elicitation_callback(
|
||||
|
|
@ -233,17 +222,87 @@ async def default_elicitation_callback(
|
|||
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")
|
||||
async def run_elicitation_defaults(server_url: str) -> None:
|
||||
"""Connect with elicitation callback that applies schema defaults."""
|
||||
async with streamable_http_client(url=server_url) as (read_stream, write_stream):
|
||||
async with ClientSession(
|
||||
read_stream, write_stream, elicitation_callback=default_elicitation_callback
|
||||
) as session:
|
||||
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}")
|
||||
async with Client(server_url, mode=client_mode(), elicitation_callback=default_elicitation_callback) as client:
|
||||
await client.list_tools()
|
||||
result = await client.call_tool("test_client_elicitation_defaults", {})
|
||||
logger.debug(f"test_client_elicitation_defaults result: {result}")
|
||||
|
||||
|
||||
@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:
|
||||
"""Common session logic for all OAuth flows."""
|
||||
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):
|
||||
async with ClientSession(
|
||||
read_stream, write_stream, elicitation_callback=default_elicitation_callback
|
||||
) as session:
|
||||
await session.initialize()
|
||||
logger.debug("Initialized successfully")
|
||||
http_client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0)
|
||||
transport = streamable_http_client(url=server_url, http_client=http_client)
|
||||
async with Client(transport, mode=client_mode(), elicitation_callback=default_elicitation_callback) as client:
|
||||
logger.debug("Initialized successfully")
|
||||
|
||||
tools_result = await session.list_tools()
|
||||
logger.debug(f"Listed tools: {[t.name for t in tools_result.tools]}")
|
||||
tools_result = await client.list_tools()
|
||||
logger.debug(f"Listed tools: {[t.name for t in tools_result.tools]}")
|
||||
|
||||
# Call the first available tool (different tests have different tools)
|
||||
if tools_result.tools:
|
||||
tool_name = tools_result.tools[0].name
|
||||
try:
|
||||
result = await session.call_tool(tool_name, {})
|
||||
logger.debug(f"Called {tool_name}, result: {result}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Tool call result/error: {e}")
|
||||
# Call the first available tool (different tests have different tools)
|
||||
if tools_result.tools:
|
||||
tool_name = tools_result.tools[0].name
|
||||
try:
|
||||
result = await client.call_tool(tool_name, {})
|
||||
logger.debug(f"Called {tool_name}, result: {result}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Tool call result/error: {e}")
|
||||
|
||||
logger.debug("Connection closed successfully")
|
||||
|
||||
|
|
@ -374,7 +430,7 @@ def main() -> None:
|
|||
|
||||
server_url = sys.argv[1]
|
||||
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:
|
||||
logger.debug(f"Running explicit scenario '{scenario}' against {server_url}")
|
||||
|
|
@ -384,6 +440,9 @@ def main() -> None:
|
|||
elif scenario.startswith("auth/"):
|
||||
asyncio.run(run_auth_code_client(server_url))
|
||||
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)
|
||||
sys.exit(1)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -21,36 +21,13 @@
|
|||
# milestone.
|
||||
|
||||
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) ---
|
||||
# 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 /
|
||||
# handle IncompleteResult yet.
|
||||
- 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-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
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -12,20 +12,12 @@
|
|||
|
||||
client:
|
||||
# --- 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 /
|
||||
# handle IncompleteResult yet.
|
||||
- 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-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 ---
|
||||
# SEP-990 (enterprise-managed authorization extension): no fixture handler /
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
### `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
|
||||
|
||||
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"))
|
||||
```
|
||||
|
||||
### `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):**
|
||||
|
||||
|
|
@ -340,15 +346,23 @@ capabilities = session.get_server_capabilities()
|
|||
**After (v2):**
|
||||
|
||||
```python
|
||||
result = session.initialize_result
|
||||
if result is not None:
|
||||
capabilities = result.capabilities
|
||||
server_info = result.server_info
|
||||
instructions = result.instructions
|
||||
version = result.protocol_version
|
||||
capabilities = session.server_capabilities
|
||||
server_info = session.server_info
|
||||
instructions = session.instructions
|
||||
version = session.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`
|
||||
|
||||
|
|
@ -764,6 +778,10 @@ async def my_tool(ctx: Context) -> 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
|
||||
|
||||
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
|
||||
|
||||
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})
|
||||
```
|
||||
|
||||
`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`:
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
### OAuth metadata URLs no longer gain a trailing slash
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -217,6 +217,12 @@ filterwarnings = [
|
|||
# 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.
|
||||
"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]
|
||||
|
|
|
|||
79
src/mcp/client/_probe.py
Normal file
79
src/mcp/client/_probe.py
Normal 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
|
||||
|
|
@ -40,7 +40,6 @@ from mcp.client.auth.utils import (
|
|||
validate_authorization_response_iss,
|
||||
validate_metadata_issuer,
|
||||
)
|
||||
from mcp.client.streamable_http import MCP_PROTOCOL_VERSION
|
||||
from mcp.shared.auth import (
|
||||
AuthorizationCodeResult,
|
||||
OAuthClientInformationFull,
|
||||
|
|
@ -54,6 +53,7 @@ from mcp.shared.auth_utils import (
|
|||
check_resource_allowed,
|
||||
resource_url_from_server_url,
|
||||
)
|
||||
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
|
||||
from mcp.shared.version import is_version_at_least
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -534,7 +534,7 @@ class OAuthClientProvider(httpx.Auth):
|
|||
await self._initialize()
|
||||
|
||||
# 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():
|
||||
# Try to refresh token
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ from httpx import Request, Response
|
|||
from pydantic import AnyUrl, ValidationError
|
||||
|
||||
from mcp.client.auth import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
|
||||
from mcp.client.streamable_http import MCP_PROTOCOL_VERSION
|
||||
from mcp.shared.auth import (
|
||||
OAuthClientInformationFull,
|
||||
OAuthClientMetadata,
|
||||
|
|
@ -13,6 +12,7 @@ from mcp.shared.auth import (
|
|||
OAuthToken,
|
||||
ProtectedResourceMetadata,
|
||||
)
|
||||
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
|
||||
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:
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -2,27 +2,34 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from contextlib import AsyncExitStack
|
||||
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 mcp import types
|
||||
from mcp.client._memory import InMemoryTransport
|
||||
from mcp.client._probe import negotiate_auto
|
||||
from mcp.client._transport import Transport
|
||||
from mcp.client.session import ClientSession, ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.server import Server
|
||||
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.jsonrpc_dispatcher import JSONRPCDispatcher
|
||||
from mcp.shared.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS
|
||||
from mcp.types import (
|
||||
CallToolResult,
|
||||
CompleteResult,
|
||||
EmptyResult,
|
||||
GetPromptResult,
|
||||
Implementation,
|
||||
InitializeResult,
|
||||
ListPromptsResult,
|
||||
ListResourcesResult,
|
||||
ListResourceTemplatesResult,
|
||||
|
|
@ -33,8 +40,87 @@ from mcp.types import (
|
|||
ReadResourceResult,
|
||||
RequestParamsMeta,
|
||||
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
|
||||
class Client:
|
||||
|
|
@ -65,7 +151,7 @@ class Client:
|
|||
server: Server[Any] | MCPServer | Transport | str
|
||||
"""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 `Transport` instance, it will be used directly.
|
||||
"""
|
||||
|
|
@ -95,58 +181,83 @@ class Client:
|
|||
client_info: Implementation | None = None
|
||||
"""Client implementation info to send to server."""
|
||||
|
||||
protocol_version: str | None = None
|
||||
"""Pin the protocol version instead of negotiating it.
|
||||
mode: ConnectMode = "auto"
|
||||
"""How to negotiate the protocol version.
|
||||
|
||||
Pinning to ``2026-07-28`` or later selects the stateless transport era: no initialize
|
||||
handshake is sent on the wire (the session synthesizes its `InitializeResult` locally),
|
||||
and for HTTP the ``MCP-Protocol-Version`` header is set from the first request. A modern
|
||||
pin currently requires a URL or `Transport`; the in-memory `Server`/`MCPServer` path
|
||||
does not yet have a modern entry point.
|
||||
Leave as ``None`` to negotiate the version via the initialize handshake.
|
||||
"""
|
||||
'auto' (the default) probes `server/discover` and falls back to the initialize handshake on legacy servers;
|
||||
for an in-process `Server`/`MCPServer` it dispatches directly without JSON-RPC framing. 'legacy' forces the
|
||||
initialize handshake (byte-identical pre-2026 behavior). A modern protocol-version string (e.g. '2026-07-28')
|
||||
adopts that version directly without a probe — supply `prior_discover` to reuse a known DiscoverResult, or
|
||||
omit it to synthesize a minimal one."""
|
||||
|
||||
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
|
||||
"""Callback for handling elicitation requests."""
|
||||
|
||||
_entered: bool = field(init=False, default=False)
|
||||
_session: ClientSession | 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:
|
||||
if isinstance(self.server, Server | MCPServer):
|
||||
self._transport = InMemoryTransport(self.server, raise_exceptions=self.raise_exceptions)
|
||||
elif isinstance(self.server, str):
|
||||
self._transport = streamable_http_client(self.server, protocol_version=self.protocol_version)
|
||||
if self.mode not in ("legacy", "auto") and self.mode not in MODERN_PROTOCOL_VERSIONS:
|
||||
hint = (
|
||||
f" ({self.mode!r} is a handshake-era version — use mode='legacy')"
|
||||
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:
|
||||
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:
|
||||
"""Enter the async context manager."""
|
||||
if self._session is not None:
|
||||
if self._entered:
|
||||
raise RuntimeError("Client is already entered; cannot reenter")
|
||||
self._entered = True
|
||||
|
||||
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(
|
||||
ClientSession(
|
||||
read_stream=read_stream,
|
||||
write_stream=write_stream,
|
||||
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,
|
||||
protocol_version=self.protocol_version,
|
||||
)
|
||||
)
|
||||
if self.mode == "legacy":
|
||||
await session.initialize()
|
||||
elif self.mode == "auto":
|
||||
await negotiate_auto(session)
|
||||
else:
|
||||
session.adopt(self.prior_discover or _synthesize_discover(self.mode))
|
||||
|
||||
await self._session.initialize()
|
||||
|
||||
# Transfer ownership to self for __aexit__ to handle
|
||||
# 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
|
||||
# handshake raised above, the local exit_stack unwinds the transport for us.
|
||||
self._session = session
|
||||
self._exit_stack = exit_stack.pop_all()
|
||||
return self
|
||||
|
||||
|
|
@ -169,22 +280,42 @@ class Client:
|
|||
raise RuntimeError("Client must be used within an async context manager")
|
||||
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
|
||||
def initialize_result(self) -> InitializeResult:
|
||||
"""The server's InitializeResult.
|
||||
def protocol_version(self) -> str:
|
||||
"""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.
|
||||
Raises RuntimeError if accessed outside the context manager.
|
||||
"""
|
||||
result = self.session.initialize_result
|
||||
if result is None: # pragma: no cover
|
||||
raise RuntimeError("Client must be used within an async context manager")
|
||||
return result
|
||||
@property
|
||||
def server_info(self) -> Implementation:
|
||||
"""Server name/version (set by initialize/discover/adopt during ``__aenter__``)."""
|
||||
return _connected(self.session.server_info)
|
||||
|
||||
@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:
|
||||
"""Send a ping request to the server."""
|
||||
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(
|
||||
self,
|
||||
progress_token: str | int,
|
||||
|
|
@ -193,7 +324,7 @@ class Client:
|
|||
message: str | None = None,
|
||||
) -> None:
|
||||
"""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=progress,
|
||||
total=total,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from types import TracebackType
|
||||
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.dispatcher import CallOptions, DispatchContext, Dispatcher, ProgressFnT
|
||||
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.message import ClientMessageMetadata, SessionMessage
|
||||
from mcp.shared.session import RequestResponder
|
||||
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 (
|
||||
CLIENT_CAPABILITIES_META_KEY,
|
||||
CLIENT_INFO_META_KEY,
|
||||
INTERNAL_ERROR,
|
||||
METHOD_NOT_FOUND,
|
||||
PROTOCOL_VERSION_META_KEY,
|
||||
UNSUPPORTED_PROTOCOL_VERSION,
|
||||
RequestId,
|
||||
RequestParamsMeta,
|
||||
)
|
||||
from mcp.types import methods as _methods
|
||||
|
||||
DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0")
|
||||
DISCOVER_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
|
@ -149,14 +197,11 @@ class ClientSession:
|
|||
message_handler: MessageHandlerFnT | None = None,
|
||||
client_info: types.Implementation | None = None,
|
||||
*,
|
||||
protocol_version: str | None = None,
|
||||
sampling_capabilities: types.SamplingCapability | None = None,
|
||||
dispatcher: Dispatcher[Any] | None = None,
|
||||
) -> None:
|
||||
self._session_read_timeout_seconds = read_timeout_seconds
|
||||
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_capabilities = sampling_capabilities
|
||||
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._message_handler = message_handler or _default_message_handler
|
||||
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
|
||||
self._initialize_result: types.InitializeResult | None
|
||||
if self._stateless_pinned:
|
||||
assert protocol_version is not None
|
||||
# A stateless-pinned session is born initialized: there is no handshake
|
||||
# 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._initialize_result: types.InitializeResult | None = None
|
||||
self._discover_result: types.DiscoverResult | None = None
|
||||
self._negotiated_version: str | None = None
|
||||
self._stamp: Callable[[dict[str, Any], CallOptions], None] = _preconnect_stamp
|
||||
self._task_group: anyio.abc.TaskGroup | None = None
|
||||
if dispatcher 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")
|
||||
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:
|
||||
if read_stream is None or write_stream is None:
|
||||
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)
|
||||
method: str = data["method"]
|
||||
opts: CallOptions = {}
|
||||
if self._stateless_pinned:
|
||||
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
|
||||
self._stamp(data, opts)
|
||||
timeout = (
|
||||
request_read_timeout_seconds
|
||||
if request_read_timeout_seconds is not None
|
||||
|
|
@ -269,12 +301,9 @@ class ClientSession:
|
|||
opts["resumption_token"] = metadata.resumption_token
|
||||
if metadata.on_resumption_token_update is not None:
|
||||
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)
|
||||
# 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:
|
||||
_methods.validate_server_result(method, version, raw)
|
||||
except KeyError:
|
||||
|
|
@ -288,7 +317,9 @@ class ClientSession:
|
|||
dropped with a debug log instead of raising.
|
||||
"""
|
||||
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:
|
||||
sampling = (
|
||||
|
|
@ -314,56 +345,180 @@ class ClientSession:
|
|||
async def initialize(self) -> types.InitializeResult:
|
||||
if self._initialize_result is not None:
|
||||
return self._initialize_result
|
||||
capabilities = self._build_capabilities()
|
||||
result = await self.send_request(
|
||||
types.InitializeRequest(
|
||||
params=types.InitializeRequestParams(
|
||||
protocol_version=self._pinned_version
|
||||
if self._pinned_version is not None
|
||||
else types.LATEST_PROTOCOL_VERSION,
|
||||
capabilities=capabilities,
|
||||
protocol_version=LATEST_HANDSHAKE_VERSION,
|
||||
capabilities=self._build_capabilities(),
|
||||
client_info=self._client_info,
|
||||
),
|
||||
),
|
||||
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}")
|
||||
|
||||
self._initialize_result = result
|
||||
self.adopt(result)
|
||||
|
||||
await self.send_notification(types.InitializedNotification())
|
||||
|
||||
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
|
||||
def initialize_result(self) -> types.InitializeResult | None:
|
||||
"""The server's InitializeResult. None until initialize() has been called.
|
||||
|
||||
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.
|
||||
"""
|
||||
"""The server's InitializeResult. None unless `initialize()` ran (or was adopted)."""
|
||||
return self._initialize_result
|
||||
|
||||
@property
|
||||
def protocol_version(self) -> str | None:
|
||||
"""Negotiated or pinned protocol version. None until initialize() unless pinned at construction.
|
||||
def discover_result(self) -> types.DiscoverResult | None:
|
||||
"""The server's DiscoverResult. None unless `discover()` ran (or was adopted).
|
||||
|
||||
Once `initialize()` has completed, this is the version the server actually
|
||||
negotiated (which can differ from a stateful pin); before that, the pin.
|
||||
Retained intact (supported_versions, ttl_ms, cache_scope) so callers
|
||||
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:
|
||||
return self._initialize_result.protocol_version
|
||||
return self._pinned_version
|
||||
return self._initialize_result.server_info
|
||||
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:
|
||||
"""Send a ping request."""
|
||||
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(
|
||||
self,
|
||||
progress_token: str | int,
|
||||
|
|
@ -563,7 +718,7 @@ class ClientSession:
|
|||
# Literal, not LATEST_PROTOCOL_VERSION: the fallback covers the initialize
|
||||
# handshake (which only exists at <=2025) and stateless until the header
|
||||
# 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:
|
||||
request = cast(types.ServerRequest, _methods.parse_server_request(method, version, params))
|
||||
except KeyError:
|
||||
|
|
@ -601,7 +756,7 @@ class ClientSession:
|
|||
) -> None:
|
||||
"""Route a server notification: validate, run the typed callback, tee to message_handler."""
|
||||
# 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:
|
||||
notification = cast(types.ServerNotification, _methods.parse_server_notification(method, version, params))
|
||||
except KeyError:
|
||||
|
|
|
|||
|
|
@ -2,10 +2,8 @@
|
|||
|
||||
from __future__ import annotations as _annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -20,14 +18,14 @@ from mcp.client._transport import TransportStreams
|
|||
from mcp.shared._compat import resync_tracer
|
||||
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
|
||||
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.version import MODERN_PROTOCOL_VERSIONS
|
||||
from mcp.types import (
|
||||
INTERNAL_ERROR,
|
||||
INVALID_REQUEST,
|
||||
METHOD_NOT_FOUND,
|
||||
PARSE_ERROR,
|
||||
ErrorData,
|
||||
InitializeResult,
|
||||
JSONRPCError,
|
||||
JSONRPCMessage,
|
||||
JSONRPCNotification,
|
||||
|
|
@ -46,25 +44,12 @@ StreamWriter = ContextSendStream[SessionMessageOrError]
|
|||
StreamReader = ContextReceiveStream[SessionMessage]
|
||||
|
||||
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"
|
||||
|
||||
# Reconnection defaults
|
||||
DEFAULT_RECONNECTION_DELAY_MS = 1000 # 1 second fallback when server doesn't provide retry
|
||||
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):
|
||||
"""Base exception for StreamableHTTP transport errors."""
|
||||
|
|
@ -88,57 +73,38 @@ class RequestContext:
|
|||
class StreamableHTTPTransport:
|
||||
"""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.
|
||||
|
||||
Args:
|
||||
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.session_id: str | None = None
|
||||
self.protocol_version: str | None = protocol_version if protocol_version in MODERN_PROTOCOL_VERSIONS else None
|
||||
|
||||
def _per_message_headers(self, message: JSONRPCMessage) -> dict[str, str]:
|
||||
"""Per-POST routing headers (Mcp-Method, Mcp-Name) for 2026-07-28+ pinned transports.
|
||||
|
||||
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
|
||||
# Captured from each stamped POST's metadata. Reused on outbound HTTP that carries
|
||||
# no per-message header (transport-internal GET/DELETE, and dispatcher-written
|
||||
# response/error/cancel POSTs that bypass the session's stamp). Cleared when an
|
||||
# `initialize` POST goes out so a probe-stamped value cannot leak onto the handshake.
|
||||
self._protocol_version_header: str | None = None
|
||||
|
||||
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,
|
||||
with these MCP-specific headers taking precedence.
|
||||
These are merged with the ``httpx.AsyncClient`` defaults (these take
|
||||
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] = {
|
||||
"accept": "application/json, text/event-stream",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
# Add session headers if available
|
||||
if self.session_id:
|
||||
headers[MCP_SESSION_ID] = self.session_id
|
||||
if self.protocol_version:
|
||||
headers[MCP_PROTOCOL_VERSION] = self.protocol_version
|
||||
if self._protocol_version_header:
|
||||
headers[MCP_PROTOCOL_VERSION_HEADER] = self._protocol_version_header
|
||||
return headers
|
||||
|
||||
def _is_initialization_request(self, message: JSONRPCMessage) -> bool:
|
||||
|
|
@ -156,29 +122,12 @@ class StreamableHTTPTransport:
|
|||
self.session_id = new_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(
|
||||
self,
|
||||
sse: ServerSentEvent,
|
||||
read_stream_writer: StreamWriter,
|
||||
original_request_id: RequestId | None = None,
|
||||
resumption_callback: Callable[[str], Awaitable[None]] | None = None,
|
||||
is_initialization: bool = False,
|
||||
) -> bool:
|
||||
"""Handle an SSE event, returning True if the response is complete."""
|
||||
if sse.event == "message":
|
||||
|
|
@ -192,10 +141,6 @@ class StreamableHTTPTransport:
|
|||
message = jsonrpc_message_adapter.validate_json(sse.data, by_name=False)
|
||||
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 original_request_id is not None and isinstance(message, JSONRPCResponse | JSONRPCError):
|
||||
message.id = original_request_id
|
||||
|
|
@ -299,10 +244,17 @@ class StreamableHTTPTransport:
|
|||
|
||||
async def _handle_post_request(self, ctx: RequestContext) -> None:
|
||||
"""Handle a POST request with response processing."""
|
||||
headers = self._prepare_headers()
|
||||
message = ctx.session_message.message
|
||||
headers.update(self._per_message_headers(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(
|
||||
"POST",
|
||||
|
|
@ -334,7 +286,13 @@ class StreamableHTTPTransport:
|
|||
pass
|
||||
logger.debug("Non-2xx body was not a JSON-RPC error; using fallback")
|
||||
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:
|
||||
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))
|
||||
|
|
@ -349,11 +307,9 @@ class StreamableHTTPTransport:
|
|||
if isinstance(message, JSONRPCRequest):
|
||||
content_type = response.headers.get("content-type", "").lower()
|
||||
if content_type.startswith("application/json"):
|
||||
await self._handle_json_response(
|
||||
response, ctx.read_stream_writer, is_initialization, request_id=message.id
|
||||
)
|
||||
await self._handle_json_response(response, ctx.read_stream_writer, request_id=message.id)
|
||||
elif content_type.startswith("text/event-stream"):
|
||||
await self._handle_sse_response(response, ctx, is_initialization)
|
||||
await self._handle_sse_response(response, ctx)
|
||||
else:
|
||||
logger.error(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,
|
||||
response: httpx.Response,
|
||||
read_stream_writer: StreamWriter,
|
||||
is_initialization: bool = False,
|
||||
*,
|
||||
request_id: RequestId,
|
||||
) -> None:
|
||||
|
|
@ -372,11 +327,6 @@ class StreamableHTTPTransport:
|
|||
try:
|
||||
content = await response.aread()
|
||||
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)
|
||||
await read_stream_writer.send(session_message)
|
||||
except (httpx.StreamError, ValidationError) as exc:
|
||||
|
|
@ -389,7 +339,6 @@ class StreamableHTTPTransport:
|
|||
self,
|
||||
response: httpx.Response,
|
||||
ctx: RequestContext,
|
||||
is_initialization: bool = False,
|
||||
) -> None:
|
||||
"""Handle SSE response from the server."""
|
||||
last_event_id: str | None = None
|
||||
|
|
@ -416,7 +365,6 @@ class StreamableHTTPTransport:
|
|||
ctx.read_stream_writer,
|
||||
original_request_id=original_request_id,
|
||||
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
|
||||
# break the loop
|
||||
|
|
@ -581,7 +529,6 @@ async def streamable_http_client(
|
|||
*,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
terminate_on_close: bool = True,
|
||||
protocol_version: str | None = None,
|
||||
) -> AsyncGenerator[TransportStreams, None]:
|
||||
"""Client transport for StreamableHTTP.
|
||||
|
||||
|
|
@ -591,8 +538,6 @@ async def streamable_http_client(
|
|||
client with recommended MCP timeouts will be created. To configure headers,
|
||||
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.
|
||||
protocol_version: Pin the MCP-Protocol-Version header for stateless 2026-07-28 sessions.
|
||||
Tracer-bullet duplication — also pass to `ClientSession(protocol_version=...)`.
|
||||
|
||||
Yields:
|
||||
Tuple containing:
|
||||
|
|
@ -610,7 +555,7 @@ async def streamable_http_client(
|
|||
# Create default client with recommended MCP timeouts
|
||||
client = create_mcp_http_client()
|
||||
|
||||
transport = StreamableHTTPTransport(url, protocol_version=protocol_version)
|
||||
transport = StreamableHTTPTransport(url)
|
||||
|
||||
logger.debug(f"Connecting to StreamableHTTP endpoint: {url}")
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Awaitable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
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.exceptions import NoBackChannelError
|
||||
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.transport_context import TransportContext
|
||||
from mcp.types import (
|
||||
INTERNAL_ERROR,
|
||||
INVALID_REQUEST,
|
||||
PARSE_ERROR,
|
||||
ClientCapabilities,
|
||||
|
|
@ -77,7 +79,7 @@ class _SingleExchangeDispatchContext:
|
|||
) -> dict[str, Any]:
|
||||
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.
|
||||
return None
|
||||
|
||||
|
|
@ -99,6 +101,27 @@ def _typed(model: type[_ModelT], raw: Any) -> _ModelT | 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(
|
||||
msg: JSONRPCResponse | JSONRPCError,
|
||||
scope: Scope,
|
||||
|
|
@ -193,5 +216,7 @@ async def handle_modern_request(
|
|||
request_id=req.id,
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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.provider import OAuthAuthorizationServerProvider
|
||||
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.inbound import MCP_PROTOCOL_VERSION_HEADER
|
||||
|
||||
|
||||
def validate_issuer_url(url: AnyHttpUrl):
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ from typing_extensions import deprecated
|
|||
from mcp.shared.dispatcher import CallOptions, Outbound
|
||||
from mcp.shared.exceptions import MCPDeprecationWarning, NoBackChannelError
|
||||
from mcp.shared.peer import Meta, dump_params
|
||||
from mcp.shared.version import LATEST_HANDSHAKE_VERSION
|
||||
from mcp.types import (
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
ClientCapabilities,
|
||||
CreateMessageRequest,
|
||||
CreateMessageResult,
|
||||
|
|
@ -93,7 +93,7 @@ class _NoChannelOutbound:
|
|||
) -> dict[str, Any]:
|
||||
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)
|
||||
|
||||
|
||||
|
|
@ -192,12 +192,12 @@ class Connection:
|
|||
|
||||
Not born-ready: `initialized` is set later by the kernel when
|
||||
`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.
|
||||
"""
|
||||
return cls(
|
||||
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,
|
||||
)
|
||||
|
||||
|
|
@ -275,14 +275,14 @@ class Connection:
|
|||
cls = result_type if result_type is not None else _RESULT_FOR[type(req)]
|
||||
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.
|
||||
|
||||
Never raises. If there's no standalone channel or the stream is broken,
|
||||
the notification is dropped and debug-logged.
|
||||
"""
|
||||
try:
|
||||
await self.outbound.notify(method, params)
|
||||
await self.outbound.notify(method, params, opts)
|
||||
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
|
||||
logger.debug("dropped %s: standalone stream closed", method)
|
||||
|
||||
|
|
|
|||
|
|
@ -94,18 +94,7 @@ class Context(BaseModel, Generic[LifespanContextT, RequestT]):
|
|||
total: Optional total value (e.g., 100)
|
||||
message: Optional message (e.g., "Starting render...")
|
||||
"""
|
||||
progress_token = self.request_context.meta.get("progress_token") if self.request_context.meta else None
|
||||
|
||||
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,
|
||||
)
|
||||
await self.request_context.session.report_progress(progress, total, message)
|
||||
|
||||
async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]:
|
||||
"""Read a resource by URI.
|
||||
|
|
|
|||
|
|
@ -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.dispatcher import DispatchContext, Dispatcher, DispatchMiddleware, OnNotify, OnRequest
|
||||
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.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 (
|
||||
CLIENT_CAPABILITIES_META_KEY,
|
||||
CLIENT_INFO_META_KEY,
|
||||
INTERNAL_ERROR,
|
||||
INVALID_PARAMS,
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
METHOD_NOT_FOUND,
|
||||
PROTOCOL_VERSION_META_KEY,
|
||||
ErrorData,
|
||||
Implementation,
|
||||
InitializeRequestParams,
|
||||
InitializeResult,
|
||||
JSONRPCError,
|
||||
JSONRPCRequest,
|
||||
JSONRPCResponse,
|
||||
RequestId,
|
||||
RequestParams,
|
||||
RequestParamsMeta,
|
||||
)
|
||||
|
|
@ -63,11 +61,11 @@ __all__ = [
|
|||
"ServerMiddleware",
|
||||
"ServerRunner",
|
||||
"aclose_shielded",
|
||||
"modern_on_request",
|
||||
"otel_middleware",
|
||||
"serve_connection",
|
||||
"serve_loop",
|
||||
"serve_one",
|
||||
"to_jsonrpc_response",
|
||||
]
|
||||
|
||||
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(
|
||||
middleware: ServerMiddleware[Any], call_next: CallNext, ctx: ServerRequestContext[Any, Any]
|
||||
) -> Awaitable[HandlerResult]:
|
||||
|
|
@ -414,7 +392,7 @@ class ServerRunner(Generic[LifespanT]):
|
|||
"""Validate `initialize` params and pick the protocol version."""
|
||||
init = InitializeRequestParams.model_validate(params or {}, by_name=False)
|
||||
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
|
||||
|
||||
def _handle_initialize(self, params: Mapping[str, Any] | None) -> InitializeResult:
|
||||
|
|
@ -494,22 +472,49 @@ async def serve_loop(
|
|||
|
||||
async def serve_one(
|
||||
server: Server[LifespanT],
|
||||
request: JSONRPCRequest,
|
||||
dctx: DispatchContext[TransportContext],
|
||||
method: str,
|
||||
params: Mapping[str, Any] | None,
|
||||
*,
|
||||
connection: Connection,
|
||||
dctx: DispatchContext[TransportContext],
|
||||
lifespan_state: LifespanT,
|
||||
) -> JSONRPCResponse | JSONRPCError:
|
||||
"""Handle a single ``request`` and return its JSON-RPC reply.
|
||||
) -> dict[str, Any]:
|
||||
"""Handle a single request ``(method, params)`` and return its result dict.
|
||||
|
||||
The single-exchange driver: builds the kernel, runs `on_request` once for
|
||||
`request` under `dctx`, maps the outcome to a `JSONRPCResponse` /
|
||||
`JSONRPCError` via `to_jsonrpc_response`, and tears down
|
||||
`connection.exit_stack` (shielded) on the way out. The entry constructs
|
||||
the (born-ready) `Connection` and the `dctx`; this only consumes them.
|
||||
The single-exchange driver: builds the kernel, runs `on_request` once under
|
||||
`dctx`, and tears down `connection.exit_stack` (shielded) on the way out.
|
||||
The entry constructs 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)
|
||||
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:
|
||||
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
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from typing_extensions import deprecated
|
|||
from mcp import types
|
||||
from mcp.server.connection import Connection
|
||||
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.message import ServerMessageMetadata
|
||||
from mcp.types import methods as _methods
|
||||
|
|
@ -36,7 +36,7 @@ class ServerSession:
|
|||
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._connection = connection
|
||||
|
||||
|
|
@ -353,6 +353,16 @@ class ServerSession:
|
|||
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(
|
||||
self,
|
||||
progress_token: str | int,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ from starlette.types import Receive, Scope, Send
|
|||
from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings
|
||||
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
|
||||
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.version import is_version_at_least
|
||||
from mcp.types import (
|
||||
|
|
@ -50,7 +51,6 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
# Header names
|
||||
MCP_SESSION_ID_HEADER = "mcp-session-id"
|
||||
MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version"
|
||||
LAST_EVENT_ID_HEADER = "last-event-id"
|
||||
|
||||
# Content types
|
||||
|
|
@ -818,7 +818,7 @@ class StreamableHTTPServerTransport:
|
|||
|
||||
async def _validate_request_headers(self, request: Request, send: Send) -> bool:
|
||||
# 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.
|
||||
return await self._validate_session(request, send)
|
||||
|
||||
|
|
|
|||
|
|
@ -19,16 +19,16 @@ from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, Authorizat
|
|||
from mcp.server.connection import Connection
|
||||
from mcp.server.runner import serve_connection, serve_loop
|
||||
from mcp.server.streamable_http import (
|
||||
MCP_PROTOCOL_VERSION_HEADER,
|
||||
MCP_SESSION_ID_HEADER,
|
||||
EventStore,
|
||||
StreamableHTTPServerTransport,
|
||||
)
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
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.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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -169,7 +169,7 @@ class StreamableHTTPSessionManager:
|
|||
# and return a structured rejection. 2025 paths below remain unchanged.
|
||||
header = MCP_PROTOCOL_VERSION_HEADER.encode("ascii")
|
||||
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)
|
||||
return
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue