Resolve protocol version per request and expose it as ctx.protocol_version (#2886)
This commit is contained in:
parent
c85ccf1c6f
commit
734746a3d9
5 changed files with 175 additions and 28 deletions
|
|
@ -87,7 +87,7 @@ class Connection:
|
|||
"""The protocol version negotiated during `initialize`; `None` before
|
||||
initialization. Stateless connections don't require the handshake, so this
|
||||
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
|
||||
"""Set when `notifications/initialized` arrives (matches TS `oninitialized`);
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ class ServerRequestContext(Generic[LifespanContextT, RequestT]):
|
|||
|
||||
session: ServerSession
|
||||
lifespan_context: LifespanContextT
|
||||
protocol_version: str
|
||||
request_id: RequestId | None = None
|
||||
meta: RequestParamsMeta | 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.exceptions import MCPError
|
||||
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.version import SUPPORTED_PROTOCOL_VERSIONS
|
||||
from mcp.types import (
|
||||
|
|
@ -42,6 +42,7 @@ from mcp.types import (
|
|||
INVALID_PARAMS,
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
METHOD_NOT_FOUND,
|
||||
PROTOCOL_VERSION_META_KEY,
|
||||
ErrorData,
|
||||
Implementation,
|
||||
InitializeRequestParams,
|
||||
|
|
@ -79,6 +80,30 @@ def _extract_meta(params: Mapping[str, Any] | None) -> RequestParamsMeta | 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:
|
||||
"""Dispatch-tier middleware that wraps each request in an OpenTelemetry span.
|
||||
|
||||
|
|
@ -218,11 +243,9 @@ class ServerRunner(Generic[LifespanT]):
|
|||
method: str,
|
||||
params: Mapping[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
ctx = self._make_context(dctx, _extract_meta(params))
|
||||
# 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.connection.protocol_version or "2025-11-25"
|
||||
meta = _extract_meta(params)
|
||||
version = _resolve_protocol_version(self.connection.protocol_version, meta, dctx.message_metadata)
|
||||
ctx = self._make_context(dctx, meta, version)
|
||||
is_spec_method = method in _methods.SPEC_CLIENT_METHODS
|
||||
|
||||
async def _inner() -> HandlerResult:
|
||||
|
|
@ -289,9 +312,9 @@ class ServerRunner(Generic[LifespanT]):
|
|||
method: str,
|
||||
params: Mapping[str, Any] | None,
|
||||
) -> None:
|
||||
ctx = self._make_context(dctx, _extract_meta(params))
|
||||
# Same fallback as `_on_request`: covers pre-handshake and stateless.
|
||||
version = self.connection.protocol_version or "2025-11-25"
|
||||
meta = _extract_meta(params)
|
||||
version = _resolve_protocol_version(self.connection.protocol_version, meta, dctx.message_metadata)
|
||||
ctx = self._make_context(dctx, meta, version)
|
||||
|
||||
async def _inner() -> None:
|
||||
if method in _methods.SPEC_CLIENT_NOTIFICATION_METHODS:
|
||||
|
|
@ -349,7 +372,7 @@ class ServerRunner(Generic[LifespanT]):
|
|||
return call
|
||||
|
||||
def _make_context(
|
||||
self, dctx: DispatchContext[TransportContext], meta: RequestParamsMeta | None
|
||||
self, dctx: DispatchContext[TransportContext], meta: RequestParamsMeta | None, protocol_version: str
|
||||
) -> ServerRequestContext[LifespanT, Any]:
|
||||
# TODO(maxisbey): remove for Context rework. Reads the SHTTP per-request
|
||||
# data off the raw `dctx.message_metadata` carrier; replace with the
|
||||
|
|
@ -366,6 +389,7 @@ class ServerRunner(Generic[LifespanT]):
|
|||
lifespan_context=self.lifespan_state,
|
||||
request_id=dctx.request_id,
|
||||
meta=meta,
|
||||
protocol_version=protocol_version,
|
||||
request=request,
|
||||
close_sse_stream=close_sse_stream,
|
||||
close_standalone_sse_stream=close_standalone_sse_stream,
|
||||
|
|
|
|||
|
|
@ -55,10 +55,8 @@ class ServerSession:
|
|||
def protocol_version(self) -> str | None:
|
||||
"""The protocol version negotiated during `initialize`.
|
||||
|
||||
`None` before initialization completes. Stateless connections don't
|
||||
require the handshake, so this is normally `None` there (on streamable
|
||||
HTTP the per-request version is the `MCP-Protocol-Version` header,
|
||||
available via `ctx.request.headers`).
|
||||
`None` before initialization, and normally `None` on stateless
|
||||
connections. For the per-request value, read `ctx.protocol_version`.
|
||||
"""
|
||||
return self._connection.protocol_version
|
||||
|
||||
|
|
|
|||
|
|
@ -248,11 +248,12 @@ class StreamableHTTPServerTransport:
|
|||
|
||||
metadata = ServerMessageMetadata(
|
||||
request_context=request,
|
||||
protocol_version=protocol_version,
|
||||
close_sse_stream=close_stream_callback,
|
||||
close_standalone_sse_stream=close_standalone_stream_callback,
|
||||
)
|
||||
else:
|
||||
metadata = ServerMessageMetadata(request_context=request)
|
||||
metadata = ServerMessageMetadata(request_context=request, protocol_version=protocol_version)
|
||||
|
||||
return SessionMessage(message, metadata=metadata)
|
||||
|
||||
|
|
@ -506,7 +507,10 @@ class StreamableHTTPServerTransport:
|
|||
await response(scope, receive, send)
|
||||
|
||||
# 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)
|
||||
await writer.send(session_message)
|
||||
|
||||
|
|
@ -529,7 +533,7 @@ class StreamableHTTPServerTransport:
|
|||
|
||||
if self.is_json_response_enabled:
|
||||
# Process the message
|
||||
metadata = ServerMessageMetadata(request_context=request)
|
||||
metadata = ServerMessageMetadata(request_context=request, protocol_version=protocol_version)
|
||||
session_message = SessionMessage(message, metadata=metadata)
|
||||
await writer.send(session_message)
|
||||
try:
|
||||
|
|
|
|||
Loading…
Reference in a new issue