Skip to content

Commit 28f500c

Browse files
committed
Add an opt-in strict_capabilities flag to Client and ClientSession
Client(..., strict_capabilities=True) rejects, before any request reaches the transport, a call to a method whose required server capability the connected server did not advertise -- for example list_resources() against a server that only advertised tools, or subscribe_resource() when the server's resources capability does not set subscribe. The rejection is an MCPError with code -32601 (METHOD_NOT_FOUND) and data set to the method, the same shape a compliant server returns for an unadvertised capability, so opting in changes where the rejection happens, not what callers catch. The default is False and unchanged: every request is sent and the server's answer is surfaced. This mirrors the TypeScript SDK's enforceStrictCapabilities option (also default-off). The same keyword-only parameter exists on ClientSession for low-level users; Client forwards it. The method-to-capability table lives in mcp_types.methods.SERVER_CAPABILITY_REQUIREMENTS next to the other per-method maps, with missing_server_capability() as its only evaluator, so the check in ClientSession.send_request is a single data-driven gate rather than a per-method condition, and the relationship is the same at every protocol version. Because the gate reads server_capabilities, a bare version pin (mode="2026-07-28" with no prior_discover=) would reject every gated method; that combination is refused at Client construction with a ValueError that names the fix. The interaction-requirements entry for the lifecycle capability rule is no longer marked untested: the new tests pin both the opt-in pre-wire rejection and the default send-and-surface behaviour.
1 parent 8f60ebe commit 28f500c

9 files changed

Lines changed: 297 additions & 10 deletions

File tree

docs/client/index.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Everything else on this page is identical across all three. Headers, subprocesse
3131
Four read-only properties, populated the moment you enter the block:
3232

3333
* `client.server_info`: the server's identity. `server_info.name` here is `"Bookshop"`, `server_info.version` is whatever the server reports.
34-
* `client.server_capabilities`: what the server can do (`tools`, `resources`, `prompts`, `completions`, ...). A capability the server doesn't have is `None`.
34+
* `client.server_capabilities`: what the server can do (`tools`, `resources`, `prompts`, `completions`, ...). A capability the server doesn't have is `None`. Pass `Client(..., strict_capabilities=True)` and the client uses this to refuse, client-side, a call whose capability the server didn't advertise: it raises `MCPError` with code `-32601` without sending anything. By default the request is sent and the server's answer comes back.
3535
* `client.protocol_version`: the protocol version the two sides agreed on. Here it is `"2026-07-28"`.
3636
* `client.instructions`: the server's `instructions=` string, or `None` if it didn't set one.
3737

@@ -145,7 +145,7 @@ The resource verbs come in pairs: two ways to list, one way to read.
145145

146146
`read_resource` returns `contents`, a list of `TextResourceContents` or `BlobResourceContents`. Same idea as tool content: narrow with `isinstance`, then read `.text` (or `.blob`).
147147

148-
A client can also **subscribe** to a resource and be told when it changes: `subscribe_resource(uri)` and `unsubscribe_resource(uri)`, same shape as everything else here. `MCPServer` doesn't implement that half. It says so up front (`server_capabilities.resources.subscribe` is `False`) and answers the request with an `MCPError`: `-32601`, *Method not found*. A server that does support subscriptions is built on the low-level `Server` (**The low-level Server**).
148+
A client can also **subscribe** to a resource and be told when it changes: `subscribe_resource(uri)` and `unsubscribe_resource(uri)`, same shape as everything else here. `MCPServer` doesn't implement that half. It says so up front (`server_capabilities.resources.subscribe` is `False`) and answers the request with an `MCPError`: `-32601`, *Method not found*. With `strict_capabilities=True` you get the same `-32601` without the round trip: the client sees `server_capabilities.resources.subscribe` is falsy and never sends the request. A server that does support subscriptions is built on the low-level `Server` (**The low-level Server**).
149149

150150
## Prompts
151151

docs/migration.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,26 @@ For an in-process `Client(server)` (where `server` is a `Server` or `MCPServer`
439439

440440
`Client.send_ping()` is deprecated (ping is removed in 2026-07-28); pin `mode='legacy'` if you need it.
441441

442+
### `Client` gains an opt-in `strict_capabilities` flag
443+
444+
`Client(..., strict_capabilities=True)` makes the client reject, before any request reaches
445+
the transport, a call to a method whose required server capability the connected server did
446+
not advertise -- for example `list_resources()` against a server that only advertised
447+
`tools`, or `subscribe_resource()` against a server whose `resources` capability does not set
448+
`subscribe`. The rejection is an `MCPError` with code `-32601` (`METHOD_NOT_FOUND`), the same
449+
code a compliant server returns for an unadvertised capability, so existing error handling is
450+
unaffected.
451+
452+
The default is `False` and is unchanged from v1: every request is sent and the server's
453+
answer is surfaced. This mirrors the TypeScript SDK's `enforceStrictCapabilities` option, and
454+
the same keyword-only parameter exists directly on `ClientSession(..., strict_capabilities=)`
455+
for low-level users -- `Client` just forwards it. The check reads
456+
`client.server_capabilities`, so a bare version pin (`mode="2026-07-28"` with no
457+
`prior_discover=`) -- where the client never asks the server what it supports and so every
458+
capability-gated method would be rejected -- is refused at construction with a `ValueError`
459+
that names the fix: supply `prior_discover=` or use `mode="auto"`. Which method needs which
460+
capability is exported as `mcp_types.methods.SERVER_CAPABILITY_REQUIREMENTS`.
461+
442462
### Unhandled `elicitation/create` returns `-32602`; unhandled `roots/list` returns `-32601`
443463

444464
When a server sends `elicitation/create` to a client that registered no `elicitation_callback`, or `roots/list` to a client that registered no `list_roots_callback`, the SDK still answers on the client's behalf with a JSON-RPC error. In v1 both answers used code `-32600` (`INVALID_REQUEST`). They now use the code the spec assigns to each case: `elicitation/create` is answered with `-32602` (`INVALID_PARAMS`), per the [elicitation error-handling section](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#error-handling) (a client with no callback declared no elicitation modes, and a request for an undeclared mode MUST be answered with `-32602`), and `roots/list` is answered with `-32601` (`METHOD_NOT_FOUND`), per the [roots error-handling section](https://modelcontextprotocol.io/specification/2025-11-25/client/roots#error-handling). The error messages (`Elicitation not supported`, `List roots not supported`) are unchanged, and `sampling/createMessage` without a `sampling_callback` still answers `-32600` — the spec assigns no code to that case.

src/mcp-types/mcp_types/methods.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
Surface maps key `(method, version)` to per-version wire types (key absence is
77
the version gate; shape validation is per schema era, i.e. 2025-11-25 for every
88
pre-2026 version and 2026-07-28 for 2026). Monolith maps key `method` to the
9-
version-free `mcp_types` models user code receives."""
9+
version-free `mcp_types` models user code receives.
10+
`SERVER_CAPABILITY_REQUIREMENTS` keys `method` to the `ServerCapabilities`
11+
attribute path it requires (version-invariant); `missing_server_capability`
12+
evaluates it."""
1013

1114
from __future__ import annotations
1215

@@ -29,11 +32,13 @@
2932
"MONOLITH_NOTIFICATIONS",
3033
"MONOLITH_REQUESTS",
3134
"MONOLITH_RESULTS",
35+
"SERVER_CAPABILITY_REQUIREMENTS",
3236
"SERVER_NOTIFICATIONS",
3337
"SERVER_REQUESTS",
3438
"SERVER_RESULTS",
3539
"SPEC_CLIENT_METHODS",
3640
"SPEC_CLIENT_NOTIFICATION_METHODS",
41+
"missing_server_capability",
3742
"parse_client_notification",
3843
"parse_client_request",
3944
"parse_client_result",
@@ -404,6 +409,55 @@
404409
"""Monolith result model (or two-arm union) per request method."""
405410

406411

412+
# --- Server capability requirements ---
413+
414+
SERVER_CAPABILITY_REQUIREMENTS: Final[Mapping[str, tuple[str, ...]]] = MappingProxyType(
415+
{
416+
"completion/complete": ("completions",),
417+
"logging/setLevel": ("logging",),
418+
"prompts/get": ("prompts",),
419+
"prompts/list": ("prompts",),
420+
"resources/list": ("resources",),
421+
"resources/read": ("resources",),
422+
"resources/subscribe": ("resources", "subscribe"),
423+
"resources/templates/list": ("resources",),
424+
"resources/unsubscribe": ("resources",),
425+
"tools/call": ("tools",),
426+
"tools/list": ("tools",),
427+
}
428+
)
429+
"""The server capability each client request method requires, as an attribute path into
430+
`ServerCapabilities`. Methods with no entry (`ping`, `initialize`, `server/discover`,
431+
`subscriptions/listen`) require no server capability. `subscriptions/listen` stays ungated on
432+
purpose: at 2026-07-28 the `resources.subscribe` capability licenses that request's
433+
`resourceSubscriptions` FIELD, a params-level fact a method-keyed table cannot express, and
434+
no spec MUST ties the method itself to a capability -- gating it here would wrongly reject a
435+
listen that asks only for `toolsListChanged`. The relationship is the same at every protocol
436+
version, so the map is keyed by method alone."""
437+
438+
439+
def missing_server_capability(method: str, capabilities: types.ServerCapabilities | None) -> str | None:
440+
"""The server capability `method` requires but `capabilities` does not advertise.
441+
442+
Returns the dotted name of the first unadvertised step on the required capability path --
443+
`resources` when no resources capability is advertised at all, `resources.subscribe` when
444+
`resources` is advertised but `subscribe` is not -- or None when `method` requires no
445+
server capability or the required one is advertised. Naming the first missing step rather
446+
than the whole path matches the TypeScript SDK's assertCapabilityForMethod and is the
447+
actionable thing to tell the caller. `capabilities=None` (nothing negotiated yet)
448+
advertises nothing.
449+
"""
450+
path = SERVER_CAPABILITY_REQUIREMENTS.get(method)
451+
if path is None:
452+
return None
453+
node: Any = capabilities
454+
for index, attr in enumerate(path):
455+
node = node and getattr(node, attr)
456+
if not node:
457+
return ".".join(path[: index + 1])
458+
return None
459+
460+
407461
# --- Parse functions ---
408462

409463
# Envelope stubs merged into bodies for surface validation (surface classes are full frames).

src/mcp/client/client.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,17 @@ async def main():
209209
"""A previously-obtained DiscoverResult to install via .adopt() when mode is a version pin.
210210
Ignored when mode='legacy'."""
211211

212+
strict_capabilities: bool = False
213+
"""Reject calls to methods whose required server capability the server did not advertise.
214+
215+
Opt-in (default False: every request is sent and the server's answer is surfaced, matching
216+
the pre-2026 client). When True, such a call raises `MCPError` with code `METHOD_NOT_FOUND`
217+
before any request reaches the transport -- the same code a compliant server returns for an
218+
unadvertised capability. The check reads `server_capabilities`, so a bare version pin
219+
(`mode='2026-07-28'` with no `prior_discover=`) -- which never asks the server what it
220+
supports -- is rejected at construction with a `ValueError`; supply `prior_discover=` or
221+
use `mode='auto'`. Mirrors the TypeScript SDK's `enforceStrictCapabilities` option."""
222+
212223
elicitation_callback: ElicitationFnT | None = None
213224
"""Callback for handling elicitation requests."""
214225

@@ -233,6 +244,13 @@ def __post_init__(self) -> None:
233244
f"mode must be 'legacy', 'auto', or one of {list(MODERN_PROTOCOL_VERSIONS)}; got {self.mode!r}{hint}"
234245
)
235246

247+
if self.strict_capabilities and self.mode in MODERN_PROTOCOL_VERSIONS and self.prior_discover is None:
248+
raise ValueError(
249+
"strict_capabilities=True with a version pin needs prior_discover=: a bare pin "
250+
"never asks the server what it supports, so every capability-gated method would "
251+
"be rejected. Supply prior_discover= or use mode='auto'."
252+
)
253+
236254
srv = self.server
237255
if isinstance(srv, MCPServer):
238256
srv = srv._lowlevel_server # pyright: ignore[reportPrivateUsage]
@@ -255,6 +273,7 @@ async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession:
255273
message_handler=self.message_handler,
256274
client_info=self.client_info,
257275
elicitation_callback=self.elicitation_callback,
276+
strict_capabilities=self.strict_capabilities,
258277
)
259278

260279
async def __aenter__(self) -> Client:

src/mcp/client/session.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ def __init__(
226226
client_info: types.Implementation | None = None,
227227
*,
228228
sampling_capabilities: types.SamplingCapability | None = None,
229+
strict_capabilities: bool = False,
229230
dispatcher: Dispatcher[Any] | None = None,
230231
) -> None:
231232
self._session_read_timeout_seconds = read_timeout_seconds
@@ -236,6 +237,7 @@ def __init__(
236237
self._list_roots_callback = list_roots_callback or _default_list_roots_callback
237238
self._logging_callback = logging_callback or _default_logging_callback
238239
self._message_handler = message_handler or _default_message_handler
240+
self._strict_capabilities = strict_capabilities
239241
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
240242
self._x_mcp_header_maps: dict[str, dict[tuple[str, ...], str]] = {}
241243
self._initialize_result: types.InitializeResult | None = None
@@ -309,11 +311,22 @@ async def send_request(
309311
metadata: Streamable HTTP resumption hints.
310312
311313
Raises:
312-
MCPError: Error response, read timeout, or connection closed.
314+
MCPError: Error response, read timeout, or connection closed. Also raised
315+
before any send when `strict_capabilities` is set and the server did not
316+
advertise the capability `request.method` requires (code
317+
`METHOD_NOT_FOUND`).
313318
RuntimeError: Called before entering the context manager.
314319
"""
315320
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
316321
method: str = data["method"]
322+
if self._strict_capabilities and (
323+
missing := _methods.missing_server_capability(method, self.server_capabilities)
324+
):
325+
raise MCPError(
326+
code=METHOD_NOT_FOUND,
327+
message=f"Server does not advertise the {missing} capability (required for {method})",
328+
data=method,
329+
)
317330
opts: CallOptions = {}
318331
self._stamp(data, opts)
319332
timeout = (

tests/client/test_client.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,14 @@ def test_client_rejects_handshake_era_mode_at_construction() -> None:
517517
Client(server, mode="not-a-version")
518518

519519

520+
def test_client_rejects_strict_capabilities_on_a_bare_version_pin_at_construction() -> None:
521+
"""`strict_capabilities=True` with a version pin and no `prior_discover=` is rejected by
522+
`__post_init__`: a bare pin never asks the server what it supports, so every
523+
capability-gated method would be rejected before doing anything useful."""
524+
with pytest.raises(ValueError, match=r"prior_discover= or use mode='auto'"):
525+
Client(MCPServer("test"), mode="2026-07-28", strict_capabilities=True)
526+
527+
520528
# ── SEP-2322 multi-round-trip auto-loop ────────────────────────────────────────
521529

522530

tests/interaction/_requirements.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -196,14 +196,15 @@ def __post_init__(self) -> None:
196196
),
197197
divergence=Divergence(
198198
note=(
199-
"The client sends any request regardless of the server's advertised capabilities and "
200-
"surfaces whatever the server answers; the spec's MUST is not enforced."
199+
"Not the default: by default the client sends any request regardless of the "
200+
"server's advertised capabilities and surfaces whatever the server answers. The "
201+
"client-side pre-check is opt-in via Client(strict_capabilities=True), which "
202+
"rejects with METHOD_NOT_FOUND before any wire traffic -- the same shape as the "
203+
"TypeScript SDK's enforceStrictCapabilities (also default-off). The 2026-07-28 "
204+
"revision removes the lifecycle page that carries this MUST; there the server's "
205+
"-32601 is the authoritative signal and the SDK's server already returns it."
201206
),
202207
),
203-
deferred=(
204-
"Not implemented in the SDK: the client sends any request regardless of the server's "
205-
"advertised capabilities and surfaces whatever the server answers."
206-
),
207208
),
208209
"lifecycle:initialize:basic": Requirement(
209210
source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization",

0 commit comments

Comments
 (0)