Skip to content

Commit 46c200d

Browse files
committed
Accept modern envelope without the optional clientInfo key (spec PR #3002)
Spec PR #3002 made io.modelcontextprotocol/clientInfo optional (SHOULD-include): the required per-request envelope is now the protocolVersion + clientCapabilities pair. - classify_inbound_request rung 1 demands the pair and reads absent clientInfo as None; a missing required key rejects -32602 with a message naming the key(s), per basic/index.mdx. - Era evidence on the stdio dual-era loop is now presence of the reserved protocolVersion _meta key alone: the io.modelcontextprotocol/ prefix is spec-reserved, so legacy traffic never mints it, and a half-built envelope (version without capabilities) routes modern to get the classifier's named rejection instead of the legacy path's generic one. Failed classification still locks no era. - The regenerated surface types (previous commit) already carry the optional clientInfo on RequestMetaObject. - Connection records client_capabilities as its own fact so capability checks (check_capability, sampling tools validation, extension and apps gates) work for pair-only requests; the client_params setter keeps the two in lockstep on the handshake path. ServerSession exposes client_capabilities and all capability consumers read it. - The Mcp-Param schema-resolving tools/list walk omits the clientInfo key from its synthetic envelope when the caller sent none.
1 parent 73a62ad commit 46c200d

13 files changed

Lines changed: 282 additions & 65 deletions

File tree

src/mcp/server/_streamable_http_modern.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,9 +218,11 @@ async def _tool_input_schema(
218218
"""
219219
meta = {
220220
PROTOCOL_VERSION_META_KEY: verdict.protocol_version,
221-
CLIENT_INFO_META_KEY: verdict.client_info,
222221
CLIENT_CAPABILITIES_META_KEY: verdict.client_capabilities,
223222
}
223+
if verdict.client_info is not None:
224+
# Optional key: a conforming pair-only caller omits it rather than sending null.
225+
meta[CLIENT_INFO_META_KEY] = verdict.client_info
224226
list_params: dict[str, Any] = {"_meta": meta}
225227
try:
226228
_methods.validate_client_request("tools/list", verdict.protocol_version, list_params)

src/mcp/server/apps.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,8 +233,7 @@ def client_supports_apps(ctx: Context[Any] | ServerRequestContext[Any, Any]) ->
233233
def _client_capabilities(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> Any:
234234
if isinstance(ctx, Context):
235235
return ctx.client_capabilities
236-
client_params = ctx.session.client_params
237-
return client_params.capabilities if client_params else None
236+
return ctx.session.client_capabilities
238237

239238

240239
def _require_ui_scheme(uri: str) -> None:

src/mcp/server/connection.py

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,13 @@ class Connection:
147147

148148
session_id: str | None
149149

150-
client_params: InitializeRequestParams | None
151-
"""The full `initialize` request params, or the equivalent built from the
152-
2026-era envelope. `None` when no client info was supplied."""
150+
client_capabilities: ClientCapabilities | None
151+
"""The capabilities the peer declared: the handshake's on the loop path,
152+
the request envelope's on the modern path. `None` when none were declared.
153+
Kept in lockstep with `client_params` by its setter, and settable on its
154+
own for the modern envelope, where capabilities are required but client
155+
info is optional (spec PR #3002) - capability checks must not depend on the
156+
peer having identified itself."""
153157

154158
protocol_version: str
155159
"""The protocol version this connection speaks. Populated at construction
@@ -180,11 +184,29 @@ def __init__(
180184
self.outbound = outbound
181185
self.protocol_version = protocol_version
182186
self.session_id = session_id
187+
self.client_capabilities = None
183188
self.client_params = client_params
184189
self.initialized = anyio.Event()
185190
self.state = {}
186191
self.exit_stack = AsyncExitStack()
187192

193+
@property
194+
def client_params(self) -> InitializeRequestParams | None:
195+
"""The full `initialize` request params, or the equivalent built from the
196+
2026-era envelope. `None` when no client info was supplied."""
197+
return self._client_params
198+
199+
@client_params.setter
200+
def client_params(self, value: InitializeRequestParams | None) -> None:
201+
# Assignment is the sync point: recording full client params (the
202+
# handshake commit, or a modern envelope carrying client info) also
203+
# records the capabilities fact, so the two can never drift. Clearing
204+
# to `None` leaves `client_capabilities` alone - the modern envelope
205+
# declares capabilities without client info.
206+
self._client_params = value
207+
if value is not None:
208+
self.client_capabilities = value.capabilities
209+
188210
@classmethod
189211
def from_envelope(
190212
cls,
@@ -201,13 +223,15 @@ def from_envelope(
201223
values. `client_info` and `client_capabilities` are the raw envelope
202224
values: this constructor owns turning them into connection identity,
203225
identically on every modern entry, so a mis-shaped value degrades to
204-
not-supplied rather than failing the request. `initialized`
205-
is set and the info/capabilities (when both supplied and well-formed)
206-
are recorded as `client_params` so capability checks work. `outbound`
207-
defaults to the no-channel sentinel for the single-exchange HTTP path;
208-
duplex modern transports (e.g. stdio) pass a notify-only wrapper
209-
around the dispatcher so server notifications ride the pipe while
210-
server-initiated requests stay refused.
226+
not-supplied rather than failing the request. `initialized` is set,
227+
well-formed capabilities are recorded as `client_capabilities` (client
228+
info is optional per spec PR #3002, so capability checks never depend on
229+
it), and the full `client_params` is additionally synthesized when
230+
client info was supplied too. `outbound` defaults to the no-channel
231+
sentinel for the single-exchange HTTP path; duplex modern transports
232+
(e.g. stdio) pass a notify-only wrapper around the dispatcher so
233+
server notifications ride the pipe while server-initiated requests
234+
stay refused.
211235
"""
212236
info = _typed(Implementation, client_info)
213237
capabilities = _typed(ClientCapabilities, client_capabilities)
@@ -219,6 +243,7 @@ def from_envelope(
219243
client_info=info,
220244
)
221245
connection = cls(outbound, protocol_version=protocol_version, client_params=client_params)
246+
connection.client_capabilities = capabilities
222247
connection.initialized.set()
223248
return connection
224249

@@ -369,13 +394,13 @@ async def send_resource_updated(self, uri: str, *, meta: Meta | None = None) ->
369394
def check_capability(self, capability: ClientCapabilities) -> bool:
370395
"""Return whether the connected client declared the given capability.
371396
372-
Returns `False` when no client info has been recorded.
397+
Returns `False` when no capabilities have been recorded.
373398
"""
374399
# TODO(L53): redesign - mirrors v1 ServerSession.check_client_capability
375400
# verbatim for parity.
376-
if self.client_params is None:
401+
if self.client_capabilities is None:
377402
return False
378-
have = self.client_params.capabilities
403+
have = self.client_capabilities
379404
if capability.roots is not None:
380405
if have.roots is None:
381406
return False

src/mcp/server/mcpserver/context.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -326,11 +326,11 @@ def request_state(self) -> str | None:
326326
def client_capabilities(self) -> ClientCapabilities | None:
327327
"""The client's declared capabilities for this connection.
328328
329-
`None` when the client supplied no client info (e.g. an anonymous
330-
stateless request without the reserved `_meta` keys).
329+
`None` when the client declared none (e.g. an anonymous stateless
330+
request without the reserved `_meta` keys). Client info is not
331+
required for capabilities to be recorded.
331332
"""
332-
client_params = self.request_context.session.client_params
333-
return client_params.capabilities if client_params else None
333+
return self.request_context.session.client_capabilities
334334

335335
@property
336336
def session(self):

src/mcp/server/mcpserver/server.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1320,8 +1320,8 @@ def require_client_extension(ctx: ServerRequestContext[Any, Any], identifier: st
13201320
MCPError: With code `MISSING_REQUIRED_CLIENT_CAPABILITY` if the client
13211321
did not advertise `identifier`.
13221322
"""
1323-
client_params = ctx.session.client_params
1324-
declared = client_params.capabilities.extensions if client_params else None
1323+
capabilities = ctx.session.client_capabilities
1324+
declared = capabilities.extensions if capabilities else None
13251325
if not declared or identifier not in declared:
13261326
data = MissingRequiredClientCapabilityErrorData(
13271327
required_capabilities=ClientCapabilities(extensions={identifier: {}})

src/mcp/server/runner.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -439,19 +439,23 @@ async def serve_loop(
439439
)
440440

441441

442-
_MODERN_ENVELOPE_KEYS = (PROTOCOL_VERSION_META_KEY, CLIENT_INFO_META_KEY, CLIENT_CAPABILITIES_META_KEY)
443-
444-
445442
def _has_modern_envelope(params: Mapping[str, Any] | None) -> bool:
446-
"""Whether `params._meta` carries every reserved modern-envelope key.
447-
448-
Era evidence is the FULL key triple - bare `_meta` is not (legacy traffic
449-
carries `progressToken` there).
443+
"""Whether `params._meta` carries the reserved protocol-version key.
444+
445+
Era evidence is the client's explicit version declaration: the
446+
`io.modelcontextprotocol/protocolVersion` key exists only in 2026-07-28+
447+
envelopes, and the `io.modelcontextprotocol/` prefix is spec-reserved, so
448+
legacy traffic never mints it (bare `_meta` is NOT evidence - legacy
449+
requests carry `progressToken` there). Presence of the version key alone
450+
is the rule, not the full required pair, so a half-built envelope
451+
(version present, capabilities missing) still routes modern and gets the
452+
classifier's INVALID_PARAMS naming the missing key instead of the legacy
453+
path's generic one - and, like every failed classification, locks no era.
450454
"""
451455
if not params:
452456
return False
453457
meta = params.get("_meta")
454-
return isinstance(meta, Mapping) and all(key in meta for key in _MODERN_ENVELOPE_KEYS)
458+
return isinstance(meta, Mapping) and PROTOCOL_VERSION_META_KEY in meta
455459

456460

457461
def _initialize_after_modern_data(params: Mapping[str, Any] | None) -> dict[str, Any]:
@@ -557,8 +561,8 @@ async def serve_dual_era_loop(
557561
like `serve_loop` for its lifetime, and modern envelope traffic is then
558562
rejected with INVALID_REQUEST. `initialize` never routes modern - the
559563
method is legacy-distinctive by definition - even when a confused
560-
client stamps the envelope triple on it.
561-
- A request carrying the modern `_meta` envelope triple - or
564+
client stamps the envelope keys on it.
565+
- A request whose `_meta` declares the modern protocol version - or
562566
`server/discover`, a modern-only method - is classified
563567
(`classify_inbound_request`) and served single-exchange via `serve_one`
564568
with a born-ready per-request `Connection`, the same dispatch model as
@@ -676,7 +680,7 @@ async def on_request(
676680
return await serve_modern(dctx, method, params)
677681
# Unlocked. `initialize` is legacy-distinctive by definition (the
678682
# method does not exist at modern versions), so it takes the handshake
679-
# path even when the envelope triple is stamped on it.
683+
# path even when the envelope keys are stamped on it.
680684
if method != "initialize" and (method == "server/discover" or _has_modern_envelope(params)):
681685
return await serve_modern(dctx, method, params)
682686
result = await loop_runner.on_request(dctx, method, params)

src/mcp/server/session.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ def client_params(self) -> types.InitializeRequestParams | None:
4545
"""The client's `initialize` request params; `None` when no client info was supplied."""
4646
return self._connection.client_params
4747

48+
@property
49+
def client_capabilities(self) -> types.ClientCapabilities | None:
50+
"""The capabilities the client declared; `None` when none were declared.
51+
52+
Prefer this over `client_params.capabilities`: on 2026-07-28+ the
53+
request envelope declares capabilities while client info stays
54+
optional, so capabilities can be present without `client_params`.
55+
"""
56+
return self._connection.client_capabilities
57+
4858
@property
4959
def can_send_request(self) -> bool:
5060
"""Whether this request's channel can currently deliver a server-initiated request."""
@@ -236,8 +246,7 @@ async def create_message(
236246
NoBackChannelError: The connection has no back-channel for
237247
server-initiated requests.
238248
"""
239-
client_caps = self.client_params.capabilities if self.client_params else None
240-
validate_sampling_tools(client_caps, tools, tool_choice)
249+
validate_sampling_tools(self.client_capabilities, tools, tool_choice)
241250
validate_tool_use_result_messages(messages)
242251

243252
request = types.CreateMessageRequest(

src/mcp/shared/inbound.py

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -327,9 +327,10 @@ def _value_at_path(arguments: Mapping[str, Any], path: tuple[str, ...]) -> Any:
327327
class InboundModernRoute:
328328
"""A modern-protocol request whose envelope passed every ladder rung.
329329
330-
`client_info` and `client_capabilities` are the raw envelope values;
331-
the classifier checks presence only, not shape. Method existence is not a
332-
ladder rung — kernel dispatch is the single source of truth for that.
330+
`client_info` and `client_capabilities` are the raw envelope values; the
331+
classifier checks presence only, not shape, and `client_info` is `None`
332+
when the (optional, SHOULD-include) key is absent. Method existence is not
333+
a ladder rung — kernel dispatch is the single source of truth for that.
333334
"""
334335

335336
protocol_version: str
@@ -376,9 +377,11 @@ def classify_inbound_request(
376377
377378
Rungs, in order — first failure wins:
378379
379-
1. `params._meta` is a mapping carrying every reserved envelope key
380-
(protocol version, client info, client capabilities) → else
381-
:data:`~mcp_types.jsonrpc.INVALID_PARAMS`.
380+
1. `params._meta` is a mapping carrying the required envelope pair
381+
(protocol version, client capabilities) → else
382+
:data:`~mcp_types.jsonrpc.INVALID_PARAMS` naming the missing key(s)
383+
(basic/index.mdx "Per-request protocol fields"). Client info is
384+
optional (SHOULD-include, spec PR #3002); absent reads as `None`.
382385
2. When `headers` is given, `MCP-Protocol-Version` equals the envelope's
383386
protocol version, `Mcp-Method` equals `body.method`, and — for the
384387
methods in :data:`NAME_BEARING_METHODS` — `Mcp-Name` equals the named
@@ -404,16 +407,24 @@ def classify_inbound_request(
404407
accepts on the per-request-envelope path.
405408
"""
406409
try:
407-
meta = body["params"]["_meta"]
408-
protocol_version = meta[PROTOCOL_VERSION_META_KEY]
409-
client_info = meta[CLIENT_INFO_META_KEY]
410-
client_capabilities = meta[CLIENT_CAPABILITIES_META_KEY]
410+
meta_value = body["params"]["_meta"]
411411
except (KeyError, TypeError):
412+
meta_value = None
413+
if not isinstance(meta_value, Mapping):
412414
return InboundLadderRejection(
413415
code=INVALID_PARAMS,
414-
message="params._meta must carry the reserved protocol-version, client-info and "
415-
"client-capabilities envelope keys",
416+
message="params._meta must be an object carrying the required "
417+
f"{PROTOCOL_VERSION_META_KEY!r} and {CLIENT_CAPABILITIES_META_KEY!r} envelope keys",
416418
)
419+
meta = cast("Mapping[str, Any]", meta_value)
420+
if missing := [key for key in (PROTOCOL_VERSION_META_KEY, CLIENT_CAPABILITIES_META_KEY) if key not in meta]:
421+
return InboundLadderRejection(
422+
code=INVALID_PARAMS,
423+
message=f"params._meta is missing the required envelope key(s): {', '.join(missing)}",
424+
)
425+
protocol_version: Any = meta[PROTOCOL_VERSION_META_KEY]
426+
client_info: Any = meta.get(CLIENT_INFO_META_KEY)
427+
client_capabilities: Any = meta[CLIENT_CAPABILITIES_META_KEY]
417428
if headers is not None:
418429
version_header = headers.get(MCP_PROTOCOL_VERSION_HEADER)
419430
# Presence is checked explicitly: a null body version would otherwise
@@ -431,8 +442,8 @@ def classify_inbound_request(
431442
)
432443
name_key = NAME_BEARING_METHODS.get(method)
433444
if name_key is not None:
434-
# Rung 1 already proved body["params"] is a mapping.
435-
body_value = body["params"].get(name_key)
445+
# Rung 1 already proved body["params"] is a mapping (its `_meta` is one).
446+
body_value = cast("Mapping[str, Any]", body["params"]).get(name_key)
436447
if body_value is not None and decode_header_value(headers.get(MCP_NAME_HEADER)) != body_value:
437448
return InboundLadderRejection(
438449
code=HEADER_MISMATCH,

tests/server/test_connection.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
ElicitationCapability,
2323
EmptyResult,
2424
Implementation,
25+
InitializeRequestParams,
2526
ListRootsRequest,
2627
ListRootsResult,
2728
PingRequest,
@@ -321,15 +322,42 @@ async def test_connection_send_tool_list_changed_with_meta_includes_meta_only_pa
321322
# --- check_capability ----------------------------------------------------------
322323

323324

324-
def test_connection_check_capability_false_when_no_client_params_recorded():
325-
"""SDK-defined: `check_capability` returns False when no `client_params`
325+
def test_connection_check_capability_false_when_no_capabilities_recorded():
326+
"""SDK-defined: `check_capability` returns False when no capabilities
326327
were recorded, regardless of which factory built the connection."""
327328
conn = Connection.for_loop(StubOutbound())
328329
assert conn.check_capability(ClientCapabilities(sampling=SamplingCapability())) is False
329330
# Same for a born-ready connection that supplied neither info nor caps.
330331
assert Connection.from_envelope(LATEST_MODERN_VERSION, None, None).check_capability(ClientCapabilities()) is False
331332

332333

334+
def test_from_envelope_records_capabilities_without_client_info():
335+
"""Spec-mandated (spec PR #3002): the envelope requires capabilities but not
336+
client info, so a pair-only request still gets working capability checks -
337+
`client_capabilities` is recorded on its own while `client_params` stays
338+
`None`."""
339+
caps = ClientCapabilities(sampling=SamplingCapability())
340+
conn = Connection.from_envelope(LATEST_MODERN_VERSION, None, caps)
341+
assert conn.client_params is None
342+
assert conn.client_capabilities == caps
343+
assert conn.check_capability(ClientCapabilities(sampling=SamplingCapability())) is True
344+
345+
346+
def test_client_params_assignment_keeps_capabilities_in_lockstep():
347+
"""SDK-defined: recording `client_params` (the loop path's handshake
348+
commit) is the sync point that also records `client_capabilities`, so the
349+
two facts cannot drift."""
350+
conn = Connection.for_loop(StubOutbound())
351+
assert conn.client_capabilities is None
352+
conn.client_params = InitializeRequestParams(
353+
protocol_version=LATEST_HANDSHAKE_VERSION,
354+
capabilities=ClientCapabilities(roots=RootsCapability()),
355+
client_info=Implementation(name="c", version="0"),
356+
)
357+
assert conn.client_capabilities == ClientCapabilities(roots=RootsCapability())
358+
assert conn.check_capability(ClientCapabilities(roots=RootsCapability())) is True
359+
360+
333361
@pytest.mark.parametrize(
334362
("have", "want", "expected"),
335363
[

0 commit comments

Comments
 (0)