Skip to content

Commit 48bc3f4

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 ced364e commit 48bc3f4

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
@@ -196,6 +196,17 @@ async def main():
196196
"""A previously-obtained DiscoverResult to install via .adopt() when mode is a version pin.
197197
Ignored when mode='legacy'."""
198198

199+
strict_capabilities: bool = False
200+
"""Reject calls to methods whose required server capability the server did not advertise.
201+
202+
Opt-in (default False: every request is sent and the server's answer is surfaced, matching
203+
the pre-2026 client). When True, such a call raises `MCPError` with code `METHOD_NOT_FOUND`
204+
before any request reaches the transport -- the same code a compliant server returns for an
205+
unadvertised capability. The check reads `server_capabilities`, so a bare version pin
206+
(`mode='2026-07-28'` with no `prior_discover=`) -- which never asks the server what it
207+
supports -- is rejected at construction with a `ValueError`; supply `prior_discover=` or
208+
use `mode='auto'`. Mirrors the TypeScript SDK's `enforceStrictCapabilities` option."""
209+
199210
elicitation_callback: ElicitationFnT | None = None
200211
"""Callback for handling elicitation requests."""
201212

@@ -215,6 +226,13 @@ def __post_init__(self) -> None:
215226
f"mode must be 'legacy', 'auto', or one of {list(MODERN_PROTOCOL_VERSIONS)}; got {self.mode!r}{hint}"
216227
)
217228

229+
if self.strict_capabilities and self.mode in MODERN_PROTOCOL_VERSIONS and self.prior_discover is None:
230+
raise ValueError(
231+
"strict_capabilities=True with a version pin needs prior_discover=: a bare pin "
232+
"never asks the server what it supports, so every capability-gated method would "
233+
"be rejected. Supply prior_discover= or use mode='auto'."
234+
)
235+
218236
srv = self.server
219237
if isinstance(srv, MCPServer):
220238
srv = srv._lowlevel_server # pyright: ignore[reportPrivateUsage]
@@ -237,6 +255,7 @@ async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession:
237255
message_handler=self.message_handler,
238256
client_info=self.client_info,
239257
elicitation_callback=self.elicitation_callback,
258+
strict_capabilities=self.strict_capabilities,
240259
)
241260

242261
async def __aenter__(self) -> Client:

src/mcp/client/session.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ def __init__(
213213
client_info: types.Implementation | None = None,
214214
*,
215215
sampling_capabilities: types.SamplingCapability | None = None,
216+
strict_capabilities: bool = False,
216217
dispatcher: Dispatcher[Any] | None = None,
217218
) -> None:
218219
self._session_read_timeout_seconds = read_timeout_seconds
@@ -223,6 +224,7 @@ def __init__(
223224
self._list_roots_callback = list_roots_callback or _default_list_roots_callback
224225
self._logging_callback = logging_callback or _default_logging_callback
225226
self._message_handler = message_handler or _default_message_handler
227+
self._strict_capabilities = strict_capabilities
226228
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
227229
self._x_mcp_header_maps: dict[str, dict[tuple[str, ...], str]] = {}
228230
self._initialize_result: types.InitializeResult | None = None
@@ -296,11 +298,22 @@ async def send_request(
296298
metadata: Streamable HTTP resumption hints.
297299
298300
Raises:
299-
MCPError: Error response, read timeout, or connection closed.
301+
MCPError: Error response, read timeout, or connection closed. Also raised
302+
before any send when `strict_capabilities` is set and the server did not
303+
advertise the capability `request.method` requires (code
304+
`METHOD_NOT_FOUND`).
300305
RuntimeError: Called before entering the context manager.
301306
"""
302307
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
303308
method: str = data["method"]
309+
if self._strict_capabilities and (
310+
missing := _methods.missing_server_capability(method, self.server_capabilities)
311+
):
312+
raise MCPError(
313+
code=METHOD_NOT_FOUND,
314+
message=f"Server does not advertise the {missing} capability (required for {method})",
315+
data=method,
316+
)
304317
opts: CallOptions = {}
305318
self._stamp(data, opts)
306319
timeout = (

tests/client/test_client.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,3 +513,11 @@ def test_client_rejects_handshake_era_mode_at_construction() -> None:
513513
Client(server, mode="2025-06-18")
514514
with pytest.raises(ValueError, match=r"mode must be 'legacy', 'auto', or one of"):
515515
Client(server, mode="not-a-version")
516+
517+
518+
def test_client_rejects_strict_capabilities_on_a_bare_version_pin_at_construction() -> None:
519+
"""`strict_capabilities=True` with a version pin and no `prior_discover=` is rejected by
520+
`__post_init__`: a bare pin never asks the server what it supports, so every
521+
capability-gated method would be rejected before doing anything useful."""
522+
with pytest.raises(ValueError, match=r"prior_discover= or use mode='auto'"):
523+
Client(MCPServer("test"), mode="2026-07-28", strict_capabilities=True)

tests/interaction/_requirements.py

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

0 commit comments

Comments
 (0)