Resolve protocol version per request and expose it as ctx.protocol_version (#2886)
This commit is contained in:
parent
c85ccf1c6f
commit
734746a3d9
11 changed files with 175 additions and 28 deletions
|
|
@ -674,7 +674,7 @@ ctx: ClientRequestContext
|
||||||
server_ctx: ServerRequestContext[LifespanContextT, RequestT]
|
server_ctx: ServerRequestContext[LifespanContextT, RequestT]
|
||||||
```
|
```
|
||||||
|
|
||||||
`ServerRequestContext` is now a standalone dataclass — it no longer subclasses `RequestContext[ServerSession]`. It carries the same fields (`session`, `request_id`, `meta`, `lifespan_context`, `request`, `close_sse_stream`, `close_standalone_sse_stream`), so handler code is unaffected, but `isinstance(ctx, RequestContext)` checks and `RequestContext[ServerSession]` annotations need updating to `ServerRequestContext`.
|
`ServerRequestContext` is now a standalone dataclass — it no longer subclasses `RequestContext[ServerSession]`. It carries the same fields (`session`, `request_id`, `meta`, `lifespan_context`, `request`, `close_sse_stream`, `close_standalone_sse_stream`) plus a new `protocol_version: str` field, so handler code is unaffected, but `isinstance(ctx, RequestContext)` checks and `RequestContext[ServerSession]` annotations need updating to `ServerRequestContext`.
|
||||||
|
|
||||||
The high-level `Context` class (injected into `@mcp.tool()` etc.) similarly dropped its `ServerSessionT` parameter: `Context[ServerSessionT, LifespanContextT, RequestT]` → `Context[LifespanContextT, RequestT]`. Both remaining parameters have defaults, so bare `Context` is usually sufficient:
|
The high-level `Context` class (injected into `@mcp.tool()` etc.) similarly dropped its `ServerSessionT` parameter: `Context[ServerSessionT, LifespanContextT, RequestT]` → `Context[LifespanContextT, RequestT]`. Both remaining parameters have defaults, so bare `Context` is usually sufficient:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ class Connection:
|
||||||
"""The protocol version negotiated during `initialize`; `None` before
|
"""The protocol version negotiated during `initialize`; `None` before
|
||||||
initialization. Stateless connections don't require the handshake, so this
|
initialization. Stateless connections don't require the handshake, so this
|
||||||
normally stays `None` there (a client that sends `initialize` anyway still
|
normally stays `None` there (a client that sends `initialize` anyway still
|
||||||
commits it). Handlers read this as `ServerSession.protocol_version`."""
|
commits it). For the per-request value, read `ctx.protocol_version`."""
|
||||||
|
|
||||||
initialized: anyio.Event
|
initialized: anyio.Event
|
||||||
"""Set when `notifications/initialized` arrives (matches TS `oninitialized`);
|
"""Set when `notifications/initialized` arrives (matches TS `oninitialized`);
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ class ServerRequestContext(Generic[LifespanContextT, RequestT]):
|
||||||
|
|
||||||
session: ServerSession
|
session: ServerSession
|
||||||
lifespan_context: LifespanContextT
|
lifespan_context: LifespanContextT
|
||||||
|
protocol_version: str
|
||||||
request_id: RequestId | None = None
|
request_id: RequestId | None = None
|
||||||
meta: RequestParamsMeta | None = None
|
meta: RequestParamsMeta | None = None
|
||||||
request: RequestT | None = None
|
request: RequestT | None = None
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ from mcp.shared._otel import extract_trace_context, otel_span
|
||||||
from mcp.shared.dispatcher import DispatchContext, DispatchMiddleware, OnRequest
|
from mcp.shared.dispatcher import DispatchContext, DispatchMiddleware, OnRequest
|
||||||
from mcp.shared.exceptions import MCPError
|
from mcp.shared.exceptions import MCPError
|
||||||
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
|
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
|
||||||
from mcp.shared.message import ServerMessageMetadata
|
from mcp.shared.message import MessageMetadata, ServerMessageMetadata
|
||||||
from mcp.shared.transport_context import TransportContext
|
from mcp.shared.transport_context import TransportContext
|
||||||
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
|
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
|
||||||
from mcp.types import (
|
from mcp.types import (
|
||||||
|
|
@ -42,6 +42,7 @@ from mcp.types import (
|
||||||
INVALID_PARAMS,
|
INVALID_PARAMS,
|
||||||
LATEST_PROTOCOL_VERSION,
|
LATEST_PROTOCOL_VERSION,
|
||||||
METHOD_NOT_FOUND,
|
METHOD_NOT_FOUND,
|
||||||
|
PROTOCOL_VERSION_META_KEY,
|
||||||
ErrorData,
|
ErrorData,
|
||||||
Implementation,
|
Implementation,
|
||||||
InitializeRequestParams,
|
InitializeRequestParams,
|
||||||
|
|
@ -79,6 +80,30 @@ def _extract_meta(params: Mapping[str, Any] | None) -> RequestParamsMeta | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_protocol_version(
|
||||||
|
negotiated: str | None,
|
||||||
|
meta: RequestParamsMeta | None,
|
||||||
|
md: MessageMetadata,
|
||||||
|
) -> str:
|
||||||
|
"""Resolve the protocol version for this inbound message.
|
||||||
|
|
||||||
|
Handshake-committed value wins; else per-request `_meta`, else the
|
||||||
|
transport hint. Unsupported values fall through so surface validation
|
||||||
|
never sees them.
|
||||||
|
"""
|
||||||
|
if negotiated is not None:
|
||||||
|
return negotiated
|
||||||
|
if meta is not None:
|
||||||
|
v = meta.get(PROTOCOL_VERSION_META_KEY)
|
||||||
|
if isinstance(v, str) and v in SUPPORTED_PROTOCOL_VERSIONS:
|
||||||
|
return v
|
||||||
|
if isinstance(md, ServerMessageMetadata):
|
||||||
|
hint = md.protocol_version
|
||||||
|
if hint is not None and hint in SUPPORTED_PROTOCOL_VERSIONS:
|
||||||
|
return hint
|
||||||
|
return "2025-11-25"
|
||||||
|
|
||||||
|
|
||||||
def otel_middleware(next_on_request: OnRequest) -> OnRequest:
|
def otel_middleware(next_on_request: OnRequest) -> OnRequest:
|
||||||
"""Dispatch-tier middleware that wraps each request in an OpenTelemetry span.
|
"""Dispatch-tier middleware that wraps each request in an OpenTelemetry span.
|
||||||
|
|
||||||
|
|
@ -218,11 +243,9 @@ class ServerRunner(Generic[LifespanT]):
|
||||||
method: str,
|
method: str,
|
||||||
params: Mapping[str, Any] | None,
|
params: Mapping[str, Any] | None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
ctx = self._make_context(dctx, _extract_meta(params))
|
meta = _extract_meta(params)
|
||||||
# Literal, not LATEST_PROTOCOL_VERSION: the fallback covers the initialize
|
version = _resolve_protocol_version(self.connection.protocol_version, meta, dctx.message_metadata)
|
||||||
# handshake (which only exists at <=2025) and stateless until the header
|
ctx = self._make_context(dctx, meta, version)
|
||||||
# is plumbed; its meaning is fixed regardless of LATEST bumps.
|
|
||||||
version = self.connection.protocol_version or "2025-11-25"
|
|
||||||
is_spec_method = method in _methods.SPEC_CLIENT_METHODS
|
is_spec_method = method in _methods.SPEC_CLIENT_METHODS
|
||||||
|
|
||||||
async def _inner() -> HandlerResult:
|
async def _inner() -> HandlerResult:
|
||||||
|
|
@ -289,9 +312,9 @@ class ServerRunner(Generic[LifespanT]):
|
||||||
method: str,
|
method: str,
|
||||||
params: Mapping[str, Any] | None,
|
params: Mapping[str, Any] | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
ctx = self._make_context(dctx, _extract_meta(params))
|
meta = _extract_meta(params)
|
||||||
# Same fallback as `_on_request`: covers pre-handshake and stateless.
|
version = _resolve_protocol_version(self.connection.protocol_version, meta, dctx.message_metadata)
|
||||||
version = self.connection.protocol_version or "2025-11-25"
|
ctx = self._make_context(dctx, meta, version)
|
||||||
|
|
||||||
async def _inner() -> None:
|
async def _inner() -> None:
|
||||||
if method in _methods.SPEC_CLIENT_NOTIFICATION_METHODS:
|
if method in _methods.SPEC_CLIENT_NOTIFICATION_METHODS:
|
||||||
|
|
@ -349,7 +372,7 @@ class ServerRunner(Generic[LifespanT]):
|
||||||
return call
|
return call
|
||||||
|
|
||||||
def _make_context(
|
def _make_context(
|
||||||
self, dctx: DispatchContext[TransportContext], meta: RequestParamsMeta | None
|
self, dctx: DispatchContext[TransportContext], meta: RequestParamsMeta | None, protocol_version: str
|
||||||
) -> ServerRequestContext[LifespanT, Any]:
|
) -> ServerRequestContext[LifespanT, Any]:
|
||||||
# TODO(maxisbey): remove for Context rework. Reads the SHTTP per-request
|
# TODO(maxisbey): remove for Context rework. Reads the SHTTP per-request
|
||||||
# data off the raw `dctx.message_metadata` carrier; replace with the
|
# data off the raw `dctx.message_metadata` carrier; replace with the
|
||||||
|
|
@ -366,6 +389,7 @@ class ServerRunner(Generic[LifespanT]):
|
||||||
lifespan_context=self.lifespan_state,
|
lifespan_context=self.lifespan_state,
|
||||||
request_id=dctx.request_id,
|
request_id=dctx.request_id,
|
||||||
meta=meta,
|
meta=meta,
|
||||||
|
protocol_version=protocol_version,
|
||||||
request=request,
|
request=request,
|
||||||
close_sse_stream=close_sse_stream,
|
close_sse_stream=close_sse_stream,
|
||||||
close_standalone_sse_stream=close_standalone_sse_stream,
|
close_standalone_sse_stream=close_standalone_sse_stream,
|
||||||
|
|
|
||||||
|
|
@ -55,10 +55,8 @@ class ServerSession:
|
||||||
def protocol_version(self) -> str | None:
|
def protocol_version(self) -> str | None:
|
||||||
"""The protocol version negotiated during `initialize`.
|
"""The protocol version negotiated during `initialize`.
|
||||||
|
|
||||||
`None` before initialization completes. Stateless connections don't
|
`None` before initialization, and normally `None` on stateless
|
||||||
require the handshake, so this is normally `None` there (on streamable
|
connections. For the per-request value, read `ctx.protocol_version`.
|
||||||
HTTP the per-request version is the `MCP-Protocol-Version` header,
|
|
||||||
available via `ctx.request.headers`).
|
|
||||||
"""
|
"""
|
||||||
return self._connection.protocol_version
|
return self._connection.protocol_version
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -248,11 +248,12 @@ class StreamableHTTPServerTransport:
|
||||||
|
|
||||||
metadata = ServerMessageMetadata(
|
metadata = ServerMessageMetadata(
|
||||||
request_context=request,
|
request_context=request,
|
||||||
|
protocol_version=protocol_version,
|
||||||
close_sse_stream=close_stream_callback,
|
close_sse_stream=close_stream_callback,
|
||||||
close_standalone_sse_stream=close_standalone_stream_callback,
|
close_standalone_sse_stream=close_standalone_stream_callback,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
metadata = ServerMessageMetadata(request_context=request)
|
metadata = ServerMessageMetadata(request_context=request, protocol_version=protocol_version)
|
||||||
|
|
||||||
return SessionMessage(message, metadata=metadata)
|
return SessionMessage(message, metadata=metadata)
|
||||||
|
|
||||||
|
|
@ -506,7 +507,10 @@ class StreamableHTTPServerTransport:
|
||||||
await response(scope, receive, send)
|
await response(scope, receive, send)
|
||||||
|
|
||||||
# Process the message after sending the response
|
# Process the message after sending the response
|
||||||
metadata = ServerMessageMetadata(request_context=request)
|
metadata = ServerMessageMetadata(
|
||||||
|
request_context=request,
|
||||||
|
protocol_version=request.headers.get(MCP_PROTOCOL_VERSION_HEADER, DEFAULT_NEGOTIATED_VERSION),
|
||||||
|
)
|
||||||
session_message = SessionMessage(message, metadata=metadata)
|
session_message = SessionMessage(message, metadata=metadata)
|
||||||
await writer.send(session_message)
|
await writer.send(session_message)
|
||||||
|
|
||||||
|
|
@ -529,7 +533,7 @@ class StreamableHTTPServerTransport:
|
||||||
|
|
||||||
if self.is_json_response_enabled:
|
if self.is_json_response_enabled:
|
||||||
# Process the message
|
# Process the message
|
||||||
metadata = ServerMessageMetadata(request_context=request)
|
metadata = ServerMessageMetadata(request_context=request, protocol_version=protocol_version)
|
||||||
session_message = SessionMessage(message, metadata=metadata)
|
session_message = SessionMessage(message, metadata=metadata)
|
||||||
await writer.send(session_message)
|
await writer.send(session_message)
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@ class ServerMessageMetadata:
|
||||||
# transports, None for stdio). Typed as Any because the server layer is
|
# transports, None for stdio). Typed as Any because the server layer is
|
||||||
# transport-agnostic.
|
# transport-agnostic.
|
||||||
request_context: Any = None
|
request_context: Any = None
|
||||||
|
# Per-message protocol version observed by the transport (e.g. the
|
||||||
|
# validated MCP-Protocol-Version header).
|
||||||
|
protocol_version: str | None = None
|
||||||
# Callback to close SSE stream for the current request without terminating
|
# Callback to close SSE stream for the current request without terminating
|
||||||
close_sse_stream: CloseSSEStreamCallback | None = None
|
close_sse_stream: CloseSSEStreamCallback | None = None
|
||||||
# Callback to close the standalone GET SSE stream (for unsolicited notifications)
|
# Callback to close the standalone GET SSE stream (for unsolicited notifications)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ async def test_progress_token_zero_first_call():
|
||||||
session=mock_session,
|
session=mock_session,
|
||||||
meta={"progress_token": 0},
|
meta={"progress_token": 0},
|
||||||
lifespan_context=None,
|
lifespan_context=None,
|
||||||
|
protocol_version="2025-11-25",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create context with our mocks
|
# Create context with our mocks
|
||||||
|
|
|
||||||
|
|
@ -1501,6 +1501,7 @@ async def test_report_progress_passes_related_request_id():
|
||||||
session=mock_session,
|
session=mock_session,
|
||||||
meta={"progress_token": "tok-1"},
|
meta={"progress_token": "tok-1"},
|
||||||
lifespan_context=None,
|
lifespan_context=None,
|
||||||
|
protocol_version="2025-11-25",
|
||||||
)
|
)
|
||||||
|
|
||||||
ctx = Context(request_context=request_context, mcp_server=MagicMock())
|
ctx = Context(request_context=request_context, mcp_server=MagicMock())
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,12 @@ import mcp.server.runner
|
||||||
from mcp.server.context import ServerRequestContext
|
from mcp.server.context import ServerRequestContext
|
||||||
from mcp.server.lowlevel.server import NotificationOptions, Server
|
from mcp.server.lowlevel.server import NotificationOptions, Server
|
||||||
from mcp.server.models import InitializationOptions
|
from mcp.server.models import InitializationOptions
|
||||||
from mcp.server.runner import ServerRunner, _extract_meta, otel_middleware
|
from mcp.server.runner import ServerRunner, _extract_meta, _resolve_protocol_version, otel_middleware
|
||||||
from mcp.server.session import ServerSession
|
from mcp.server.session import ServerSession
|
||||||
from mcp.shared.dispatcher import DispatchContext, DispatchMiddleware, OnRequest
|
from mcp.shared.dispatcher import DispatchContext, DispatchMiddleware, OnRequest
|
||||||
from mcp.shared.exceptions import MCPError
|
from mcp.shared.exceptions import MCPError
|
||||||
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
|
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
|
||||||
|
from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata
|
||||||
from mcp.shared.peer import dump_params
|
from mcp.shared.peer import dump_params
|
||||||
from mcp.shared.transport_context import TransportContext
|
from mcp.shared.transport_context import TransportContext
|
||||||
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
|
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
|
||||||
|
|
@ -31,6 +32,7 @@ from mcp.types import (
|
||||||
INVALID_PARAMS,
|
INVALID_PARAMS,
|
||||||
LATEST_PROTOCOL_VERSION,
|
LATEST_PROTOCOL_VERSION,
|
||||||
METHOD_NOT_FOUND,
|
METHOD_NOT_FOUND,
|
||||||
|
PROTOCOL_VERSION_META_KEY,
|
||||||
CallToolRequestParams,
|
CallToolRequestParams,
|
||||||
ClientCapabilities,
|
ClientCapabilities,
|
||||||
ErrorData,
|
ErrorData,
|
||||||
|
|
@ -41,6 +43,7 @@ from mcp.types import (
|
||||||
PaginatedRequestParams,
|
PaginatedRequestParams,
|
||||||
ProgressNotificationParams,
|
ProgressNotificationParams,
|
||||||
RequestParams,
|
RequestParams,
|
||||||
|
RequestParamsMeta,
|
||||||
SetLevelRequestParams,
|
SetLevelRequestParams,
|
||||||
Tool,
|
Tool,
|
||||||
)
|
)
|
||||||
|
|
@ -218,6 +221,7 @@ async def test_runner_routes_to_handler_and_builds_context(server: SrvT):
|
||||||
assert isinstance(ctx.session, ServerSession)
|
assert isinstance(ctx.session, ServerSession)
|
||||||
assert ctx.session is runner.session
|
assert ctx.session is runner.session
|
||||||
assert ctx.request_id is not None
|
assert ctx.request_id is not None
|
||||||
|
assert ctx.protocol_version == LATEST_PROTOCOL_VERSION
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
@ -650,6 +654,65 @@ async def test_runner_server_middleware_wraps_notifications(server: SrvT):
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_protocol_version_handshake_committed_value_wins():
|
||||||
|
md = ServerMessageMetadata(protocol_version="2025-03-26")
|
||||||
|
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: "2025-03-26"}
|
||||||
|
assert _resolve_protocol_version("2025-06-18", meta, md) == "2025-06-18"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_protocol_version_reads_per_request_meta_when_no_handshake():
|
||||||
|
md = ServerMessageMetadata(protocol_version="2025-03-26")
|
||||||
|
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: "2025-06-18"}
|
||||||
|
assert _resolve_protocol_version(None, meta, md) == "2025-06-18"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_protocol_version_skips_unsupported_meta_value():
|
||||||
|
md = ServerMessageMetadata(protocol_version="2025-03-26")
|
||||||
|
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: "1900-01-01"}
|
||||||
|
assert _resolve_protocol_version(None, meta, md) == "2025-03-26"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_protocol_version_skips_non_string_meta_value():
|
||||||
|
md = ServerMessageMetadata(protocol_version="2025-03-26")
|
||||||
|
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: 42}
|
||||||
|
assert _resolve_protocol_version(None, meta, md) == "2025-03-26"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_protocol_version_reads_transport_hint_when_no_handshake_or_meta():
|
||||||
|
md = ServerMessageMetadata(protocol_version="2025-06-18")
|
||||||
|
assert _resolve_protocol_version(None, None, md) == "2025-06-18"
|
||||||
|
assert _resolve_protocol_version(None, {}, md) == "2025-06-18"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_protocol_version_skips_unsupported_transport_hint():
|
||||||
|
"""The `initialize` params version reaches the metadata unvalidated; surface validation must never see it."""
|
||||||
|
md = ServerMessageMetadata(protocol_version="1900-01-01")
|
||||||
|
assert _resolve_protocol_version(None, None, md) == "2025-11-25"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_protocol_version_terminal_default_with_no_signals():
|
||||||
|
assert _resolve_protocol_version(None, None, None) == "2025-11-25"
|
||||||
|
assert _resolve_protocol_version(None, None, ServerMessageMetadata()) == "2025-11-25"
|
||||||
|
assert _resolve_protocol_version(None, None, ClientMessageMetadata()) == "2025-11-25"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_runner_ctx_protocol_version_is_terminal_default_on_stateless_in_memory(server: SrvT):
|
||||||
|
async with connected_runner(server, initialized=False, stateless=True) as (client, runner):
|
||||||
|
await client.send_raw_request("tools/list", None)
|
||||||
|
ctx = _seen_ctx[0]
|
||||||
|
assert ctx.protocol_version == "2025-11-25"
|
||||||
|
assert ctx.session.protocol_version is None
|
||||||
|
assert runner.connection.protocol_version is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_runner_ctx_protocol_version_tracks_per_request_meta_on_stateless(server: SrvT):
|
||||||
|
async with connected_runner(server, initialized=False, stateless=True) as (client, _):
|
||||||
|
await client.send_raw_request("tools/list", {"_meta": {PROTOCOL_VERSION_META_KEY: "2025-06-18"}})
|
||||||
|
assert _seen_ctx[0].protocol_version == "2025-06-18"
|
||||||
|
|
||||||
|
|
||||||
def test_extract_meta_returns_none_for_absent_or_malformed():
|
def test_extract_meta_returns_none_for_absent_or_malformed():
|
||||||
"""Context construction is independent of `_meta` validity; the params
|
"""Context construction is independent of `_meta` validity; the params
|
||||||
validation inside `call_next()` is what surfaces the error."""
|
validation inside `call_next()` is what surfaces the error."""
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ from mcp.shared._context_streams import create_context_streams
|
||||||
from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, SessionMessage
|
from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, SessionMessage
|
||||||
from mcp.shared.session import RequestResponder
|
from mcp.shared.session import RequestResponder
|
||||||
from mcp.types import (
|
from mcp.types import (
|
||||||
|
DEFAULT_NEGOTIATED_VERSION,
|
||||||
CallToolRequestParams,
|
CallToolRequestParams,
|
||||||
CallToolResult,
|
CallToolResult,
|
||||||
InitializeResult,
|
InitializeResult,
|
||||||
|
|
@ -81,14 +82,18 @@ BASE_URL = "http://127.0.0.1:8000"
|
||||||
|
|
||||||
|
|
||||||
# Helper functions
|
# Helper functions
|
||||||
def extract_protocol_version_from_sse(response: httpx.Response) -> str:
|
def first_sse_data(response: httpx.Response) -> dict[str, Any]:
|
||||||
"""Extract the negotiated protocol version from an SSE initialization response."""
|
"""Return the first SSE `data:` payload of a response, parsed as JSON."""
|
||||||
assert response.headers.get("Content-Type") == "text/event-stream"
|
assert response.headers.get("Content-Type") == "text/event-stream"
|
||||||
for line in response.text.splitlines():
|
for line in response.text.splitlines():
|
||||||
if line.startswith("data: "):
|
if line.startswith("data: "):
|
||||||
init_data = json.loads(line[6:])
|
return json.loads(line.removeprefix("data: "))
|
||||||
return init_data["result"]["protocolVersion"]
|
raise ValueError("No data event in SSE response") # pragma: no cover
|
||||||
raise ValueError("Could not extract protocol version from SSE response") # pragma: no cover
|
|
||||||
|
|
||||||
|
def extract_protocol_version_from_sse(response: httpx.Response) -> str:
|
||||||
|
"""Extract the negotiated protocol version from an SSE initialization response."""
|
||||||
|
return first_sse_data(response)["result"]["protocolVersion"]
|
||||||
|
|
||||||
|
|
||||||
# Simple in-memory event store for testing
|
# Simple in-memory event store for testing
|
||||||
|
|
@ -1318,13 +1323,14 @@ async def _handle_context_call_tool(ctx: ServerRequestContext, params: CallToolR
|
||||||
"headers": dict(ctx.request.headers),
|
"headers": dict(ctx.request.headers),
|
||||||
"method": ctx.request.method,
|
"method": ctx.request.method,
|
||||||
"path": ctx.request.url.path,
|
"path": ctx.request.url.path,
|
||||||
|
"protocol_version": ctx.protocol_version,
|
||||||
|
"session_protocol_version": ctx.session.protocol_version,
|
||||||
}
|
}
|
||||||
return CallToolResult(content=[TextContent(type="text", text=json.dumps(context_data))])
|
return CallToolResult(content=[TextContent(type="text", text=json.dumps(context_data))])
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@asynccontextmanager
|
||||||
async def context_app() -> AsyncIterator[Starlette]:
|
async def _run_context_app(*, stateless: bool) -> AsyncIterator[Starlette]:
|
||||||
"""An app whose server echoes request context, served in process."""
|
|
||||||
server = Server(
|
server = Server(
|
||||||
"ContextAwareServer",
|
"ContextAwareServer",
|
||||||
on_list_tools=_handle_context_list_tools,
|
on_list_tools=_handle_context_list_tools,
|
||||||
|
|
@ -1332,6 +1338,7 @@ async def context_app() -> AsyncIterator[Starlette]:
|
||||||
)
|
)
|
||||||
session_manager = StreamableHTTPSessionManager(
|
session_manager = StreamableHTTPSessionManager(
|
||||||
app=server,
|
app=server,
|
||||||
|
stateless=stateless,
|
||||||
security_settings=TransportSecuritySettings(enable_dns_rebinding_protection=False),
|
security_settings=TransportSecuritySettings(enable_dns_rebinding_protection=False),
|
||||||
)
|
)
|
||||||
app = Starlette(routes=[Mount("/mcp", app=session_manager.handle_request)])
|
app = Starlette(routes=[Mount("/mcp", app=session_manager.handle_request)])
|
||||||
|
|
@ -1339,6 +1346,51 @@ async def context_app() -> AsyncIterator[Starlette]:
|
||||||
yield app
|
yield app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def context_app() -> AsyncIterator[Starlette]:
|
||||||
|
"""An app whose server echoes request context, served in process."""
|
||||||
|
async with _run_context_app(stateless=False) as app:
|
||||||
|
yield app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def stateless_context_app() -> AsyncIterator[Starlette]:
|
||||||
|
async with _run_context_app(stateless=True) as app:
|
||||||
|
yield app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("header_value", "expected"),
|
||||||
|
[
|
||||||
|
("2025-06-18", "2025-06-18"),
|
||||||
|
("2025-11-25", "2025-11-25"),
|
||||||
|
(None, DEFAULT_NEGOTIATED_VERSION),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_streamablehttp_stateless_ctx_protocol_version_tracks_the_header(
|
||||||
|
stateless_context_app: Starlette, header_value: str | None, expected: str
|
||||||
|
) -> None:
|
||||||
|
"""No handshake on stateless: the header (or the spec's 2025-03-26 default) reaches `ctx.protocol_version`."""
|
||||||
|
body = JSONRPCRequest(
|
||||||
|
jsonrpc="2.0",
|
||||||
|
id=1,
|
||||||
|
method="tools/call",
|
||||||
|
params={"name": "echo_context", "arguments": {"request_id": "r"}},
|
||||||
|
)
|
||||||
|
headers = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}
|
||||||
|
if header_value is not None:
|
||||||
|
headers[MCP_PROTOCOL_VERSION_HEADER] = header_value
|
||||||
|
async with make_client(stateless_context_app) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{BASE_URL}/mcp", json=body.model_dump(by_alias=True, exclude_none=True), headers=headers
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
echoed = json.loads(first_sse_data(response)["result"]["content"][0]["text"])
|
||||||
|
assert echoed["protocol_version"] == expected
|
||||||
|
assert echoed["session_protocol_version"] is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_streamablehttp_request_context_propagation(context_app: Starlette) -> None:
|
async def test_streamablehttp_request_context_propagation(context_app: Starlette) -> None:
|
||||||
"""Custom HTTP headers on the connection are visible to server handlers via ctx.request."""
|
"""Custom HTTP headers on the connection are visible to server handlers via ctx.request."""
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue