Add a client extension API#3034
Conversation
ClientExtension, ResultClaim, NotificationBinding, ClaimContext, and the advertise() factory in mcp/client/extension.py: a closed declarative surface mirroring the server Extension, with all shape rules enforced at construction. The extension identifier grammar moves to mcp/shared/extension.py (one source of truth for both sides); the server module re-exports it.
Request gains a name_param ClassVar naming the wire-params key to mirror into the Mcp-Name header; send_request emits it whenever the stamp has not already set the header, so core NAME_BEARING_METHODS rows win by ordering and a missing value fails loud instead of silently omitting the header. send_request's typing widens to accept any Request[Any, Any] (runtime was always duck-typed), retiring the cast ceremony in the extension and custom-method stories. dispatch_input_request and validate_tool_result are promoted to public ClientSession surface.
Emission must hold with no version adopted at all, and a dropped None params must still surface the documented ValueError. Also snapshot the SDK-authored failure messages instead of regex-matching them.
Claims arrive keyed by their owning extension identifier; at adopt() the active set is computed for the negotiated version and the tools/call result adapter is rebuilt as a discriminated union (zero active claims keeps the module-level adapter, byte-identical parsing). The capability ad is built per version through the same association, so an extension whose claims are all inactive is not advertised — the ad and the claims dissolve together, on the initialize, discover-probe, and modern-stamp paths alike. call_tool gains allow_claimed: a claimed shape raises UnexpectedClaimedResult carrying the parsed value unless allowed. Notification bindings deliver through per-binding bounded FIFOs with one serialized consumer task each; enqueue never awaits, and overflow drops the oldest event with a warning. CORE_RESULT_TYPES derives from the ResultType literal in mcp-types, one source of truth for both the claim constructor and the session.
Reject an empty claim sequence at the session constructor: the ad-filter would otherwise treat the identifier as claim-bearing and silently drop it from the capability ad at every version, leaving the invariant to caller discipline. Validate ResultClaim.method against the closed verb set at construction so an unchecked runtime value cannot fold into tools/call parsing. Create notification binding queues before the dispatcher starts so the enqueue path indexes a complete dict by construction rather than by scheduling order. Copy the extensions ad dict at the constructor boundary. Pins added: modern re-adoption after legacy reactivates claims; a legacy-version discover probe drops claim-bearing identifiers from its ad.
Client.extensions becomes Sequence[ClientExtension] | None: instances are validated and read once at construction (identifier guard and grammar, duplicate identifiers, cross-extension claim and binding conflicts named by their owning extensions) and folded into the session as the capability ad, the claims-by-identifier mapping, and the binding list. call_tool resolves claimed shapes transparently: the retry closure allows claimed results through the multi-round-trip driver, the owning claim's resolver finishes the call, and non-error resolver products get the same output-schema revalidation as the direct path. The dict form of extensions= is replaced; advertise() covers ad-only uses, documented in the migration guide. No extensions means a session constructed byte-identically to before.
A Mapping passed as extensions= gets a migration error naming advertise() instead of an attribute error about str; a self-conflicting extension reads as one owner instead of "extensions 'a' and 'a'". Pins added: claims() and notifications() are read exactly once like settings(), and a claimed shape routes to its owning extension's resolver when two claim-bearing extensions are registered.
docs/advanced/extensions.md gains the client half: using an extension (construct, pass extensions=[...], call tools normally), writing one (identifier, claims with a resolver doing real follow-up sends, notifications, read-once settings), and extension verbs via Request subclasses with name_param. Two runnable tutorials back the page. Interaction tests prove the five-sentence story over the real harness: the both-ends claimed-result loop with a resolver follow-up, the off-switch (undeclared shape fails validation; legacy ad drops claim-bearing identifiers), per-request capability gating with -32021 refusal, and Mcp-Name from name_param observed on the modern HTTP wire.
A short-circuiting tools/call interceptor's dict is passed through by the runner as a trusted well-formed result — nothing strips vendor top-level fields on the way to the client. The docs, tutorials, and test fixtures previously claimed the opposite and string-packed payloads into requestState; the claim models now carry real vendor fields end to end (the settings-echo test gains a dict-typed field instead of JSON string-packing). Also: the closed-surface bullet now says notification bindings shadowed by core vocabulary go quiet rather than being rejected, the core-subclass rule is scoped to the verb's result types, and the notification-binding contribution kind gets a deferred manifest entry naming its session-tier coverage.
The accepted/rejected identifier parametrizations exercise the raw validator, which lives in mcp.shared.extension; the server suite keeps its class-level validation tests.
A claim tagged 'core' could collide with the adapter's internal routing sentinel and hijack ordinary tools/call parsing; the sentinel is now derived to never equal a claimed tag. Binding queues close in finally blocks so a raising task-group exit cannot leak them. The mapping-form migration error now also covers an empty dict; claim dedup keys by resultType alone (the verb is single-valued at construction, and the activation map already keys by tag); the one-time private-spelling tree-grep test is gone; docstring backticks and a stray __future__ import brought in line with repo conventions.
📚 Documentation preview
|
There was a problem hiding this comment.
3 issues found across 37 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="docs_src/extensions/tutorial006.py">
<violation number="1" location="docs_src/extensions/tutorial006.py:35">
P2: Gate the receipt substitution on the client declaring `com.example/receipts`; otherwise this example server sends a claimed result shape to clients that did not opt in and breaks the extension off-by-default contract.</violation>
</file>
<file name="src/mcp/client/session.py">
<violation number="1" location="src/mcp/client/session.py:1004">
P1: Claimed tools/call results are rejected by core result validation before the extension adapter runs, so the new client claim path cannot receive advertised claimed shapes.</violation>
</file>
<file name="src/mcp/client/extension.py">
<violation number="1" location="src/mcp/client/extension.py:92">
P2: ResultClaim should reject models that do not subclass mcp_types.Result. Otherwise a plain BaseModel can be routed as a claimed tool result while losing Result metadata and breaking the documented session return contract.</violation>
</file>
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Fix all with cubic | Re-trigger cubic
| ), | ||
| ), | ||
| _CallToolResultAdapter, | ||
| self._call_tool_adapter, |
There was a problem hiding this comment.
P1: Claimed tools/call results are rejected by core result validation before the extension adapter runs, so the new client claim path cannot receive advertised claimed shapes.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/client/session.py, line 1004:
<comment>Claimed tools/call results are rejected by core result validation before the extension adapter runs, so the new client claim path cannot receive advertised claimed shapes.</comment>
<file context>
@@ -760,16 +1001,20 @@ async def call_tool(
),
),
- _CallToolResultAdapter,
+ self._call_tool_adapter,
request_read_timeout_seconds=read_timeout_seconds,
progress_callback=progress_callback,
</file context>
Comments state only non-inferable constraints, one line where possible; docstrings follow Google format with a single-sentence summary and tightened Raises sections; development narration and restated-code comments are gone. Prose sticks to ASCII.
There was a problem hiding this comment.
1 issue found across 22 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/mcp/client/session.py">
<violation number="1" location="src/mcp/client/session.py:1004">
P1: Claimed tools/call results are rejected by core result validation before the extension adapter runs, so the new client claim path cannot receive advertised claimed shapes.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
examples/stories/custom_methods/README.md:28-36— The Caveats section just below this still claims the Python client silently drops any vendor server notification (ClientSession._on_notify→KeyError→ silent drop) and that there is "noset_notification_handleranalogue" — but this PR adds exactly that mechanism viaNotificationBinding/ClientExtension.notifications()/ClientSession(notification_bindings=...). Since the PR already edits the adjacent "What to look at" bullet in this README, consider rewording the caveat to point at the new notification-binding API (or to note that only this example omits the notification half, not the SDK).Extended reasoning...
What's stale. The Caveats section of
examples/stories/custom_methods/README.md(the paragraph immediately below the lines this PR edits) still reads:The Python client currently drops any notification whose method is not in the spec registry (
ClientSession._on_notify→KeyError→ silent drop), and there is noset_notification_handleranalogue. That half is omitted here.Both halves of that claim are invalidated by this PR. The
KeyErrorbranch ofClientSession._on_notify(src/mcp/client/session.py) now consultsself._notification_bindingsbefore falling back to the silent drop, delivering validated params to bound handlers through per-binding bounded FIFOs. AndNotificationBinding— registered viaClientExtension.notifications()at theClienttier orClientSession(notification_bindings=...)at the session tier — is precisely the handler-registration analogue the caveat says doesn't exist.Why it matters. The repo's convention is that docs invalidated by a code change get updated in the same PR, and this PR does touch this very README: the "What to look at" bullet about the
send_requestcast was updated to reflect the widened typing, but the caveat two lines below was left asserting a capability gap the PR removes. After merge, a reader of this example would conclude the SDK cannot observe vendor server notifications and skip a feature that now exists.Step-by-step proof that the claim is now false. (1) A user defines
class Events(ClientExtension)withidentifier = "com.acme/events"andnotifications()returning[NotificationBinding(method="acme/searchProgress", params_type=ProgressParams, handler=on_progress)]. (2) They openClient(target, extensions=[Events()]);_fold_extensionsthreads the binding intoClientSession(notification_bindings=...). (3) The server emits anacme/searchProgressnotification. (4)_on_notifyfails the core-table parse withKeyError, looks upself._notification_bindings["acme/searchProgress"], validates the params againstProgressParams, and enqueues them; the per-binding consumer task awaitson_progress(params)in arrival order. The notification is delivered, not dropped — exactly the TS-SDKsetNotificationHandlerscenario the caveat says cannot be replicated.tests/client/test_session_notification_bindings.py::test_bound_vendor_notifications_are_delivered_in_orderpins this end to end.Nuances the refutation/mitigations raise. It is true that the default (no bindings) behaviour is still a silent drop, that the new API is construction-time bindings rather than a literal
set_notification_handlersetter, and that the example may still legitimately omit the notification half (there is no public server-side surface emitting vendor notifications yet —ServerNotificationis a closed union, per the PR description). None of that rescues the sentence as written: its load-bearing claim is that the client has no mechanism at all, which is no longer true. The caveat needs a reword, not a deletion. (The one verifier refutation in the record was only a duplicate-tracking note about bug_003 vs bug_001, not a challenge to the finding itself.)Suggested fix. Reword the caveat along the lines of: "The TypeScript SDK's equivalent example also shows a custom server→client notification (
acme/searchProgress). The Python SDK can observe vendor notifications viaNotificationBinding/ClientExtension.notifications()(seeextensions/anddocs/advanced/extensions.md); this example omits that half because the lowlevel server has no surface for emitting vendor notifications yet."
| from mcp import Client | ||
| from mcp.client import advertise |
There was a problem hiding this comment.
🟡 Adding the from mcp.client import advertise import at line 2 of docs_src/apps/tutorial001.py shifts every following line down by one, but docs/advanced/apps.md (untouched by this PR) embeds this file twice with stale hl_lines: line 23's hl_lines="18 21 29 31" now highlights blank lines (the 'Four moves' constructs are at 19, 22, 30, 32), and line 54's hl_lines="22-26" now starts on the decorator and drops the final return now branch (should be 23-27). The PR made the equivalent hl_lines bumps in docs/advanced/extensions.md for tutorial004.py — apps.md just needs the same +1 adjustment.
Extended reasoning...
The bug. This PR migrates docs_src/apps/tutorial001.py from the dict-form extensions={...} to extensions=[advertise(...)], which requires a new import: from mcp.client import advertise is inserted as line 2. Every subsequent line of the tutorial therefore moves down by one. The tutorial is embedded (via --8<--) twice in docs/advanced/apps.md, and both embeddings carry hl_lines ranges that were calibrated to the pre-PR line numbering. Because apps.md itself is not touched by the PR, those ranges are now off by one and the rendered docs page highlights the wrong lines.
Where it manifests. docs/advanced/apps.md:23 opens the first code block with hl_lines="18 21 29 31". The surrounding prose ('Four moves:') enumerates exactly four constructs: apps = Apps(), the @apps.tool(resource_uri=...) decorator, apps.add_html_resource(...), and MCPServer("clock", extensions=[apps]). In the post-PR file those sit at lines 19, 22, 30, and 32 respectively — and lines 18, 21, 29, and 31 are all blank lines. So the first block highlights four empty lines instead of the four moves the prose walks through. docs/advanced/apps.md:54 opens the second embedding with hl_lines="22-26", intended to cover def get_time through both return branches (the 'one tool, two answers' degradation pattern discussed immediately below it). Post-PR that span starts at the decorator (line 22) and ends at return f"The time is {now}." (line 26), dropping the final return now branch — half of the very pattern the section is explaining.
Step-by-step proof. (1) Pre-PR, tutorial001.py line 1 was from mcp import Client and line 2 was from mcp.server.apps import ...; apps = Apps() was at 18, the decorator at 21, add_html_resource at 29, MCPServer(...) at 31 — matching hl_lines="18 21 29 31" exactly, and def get_time…return now spanned 22–26 — matching hl_lines="22-26". (2) The PR inserts from mcp.client import advertise as line 2 (visible in the diff hunk @@ -1,4 +1,5 @@). (3) In the post-PR file (verified by reading it): line 18 is blank, 19 is apps = Apps(); 21 is blank, 22 is the decorator; 29 is blank, 30 is add_html_resource; 31 is blank, 32 is MCPServer(...); def get_time through return now is now 23–27. (4) Therefore the first block highlights only blank lines and the second block highlights 22–26 = decorator through the fallback return, omitting return now.
Why nothing catches it. hl_lines is a pure mkdocs/pymdownx rendering directive — no test, type checker, or docs build error fires when the numbers point at blank or wrong lines. The docs test suite (tests/docs_src/test_apps.py) runs the tutorial code, not the markdown highlighting. The PR clearly knew about this class of drift: it bumped the equivalent hl_lines in docs/advanced/extensions.md for the identical one-line shift in tutorial004.py (15-21 → 16-22, 53-57 → 54-58), so apps.md was simply missed.
Impact and fix. Documentation rendering only — the published 'MCP Apps' page will highlight blank/incorrect lines in both embedded code blocks, undermining the prose that points readers at the highlighted lines. Fix: in docs/advanced/apps.md, change line 23 to hl_lines="19 22 30 32" and line 54 to hl_lines="23-27", mirroring the bumps the PR already made in extensions.md.
ResultClaim now rejects models that do not subclass Result at runtime (the typing bound only constrains checked callers) and models whose fields alias requestState or inputRequests, which the core result surface types and would reject before the claim adapter runs. The notification ordering contract is stated honestly: per-binding serial delivery in dispatch order, with near-simultaneous notifications on stream transports possibly dispatched out of wire order. The receipts tutorial server now gates substitution on the client declaring the extension, the stale custom-methods README caveat points at NotificationBinding, and the capability-ad docstring no longer overstates which identifiers drop.
There was a problem hiding this comment.
1 issue found across 8 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/mcp/client/session.py">
<violation number="1" location="src/mcp/client/session.py:1004">
P1: Claimed tools/call results are rejected by core result validation before the extension adapter runs, so the new client claim path cannot receive advertised claimed shapes.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Fix all with cubic | Re-trigger cubic
|
Responses to the review feedback, addressed in 99fec3c: Fixed
Not changed, deliberately
|
A claim model could route a reserved key through validation_alias, serialization_alias, or an AliasChoices entry and still hit the core pre-validation dead end the reservation exists to prevent. _wire_keys collects every top-level key a field can read from or write to, and the reservation checks the full set.
|
Follow-up on the latest review rounds, addressed in 53a538a: Fixed
Already fixed at the reviewed commit
Standing as previously stated
|
Adds a client-side extension API: the counterpart to the server
Extensionclass from #3003, so SEP-2133 extensions can contribute client behaviour — not just a capability ad — without core SDK changes.Motivation and Context
The spec's extension mechanism is deliberately SDK-shaped: capabilities ride
ClientCapabilities.extensions, servers may substitute extension result shapes ontools/call(supported set = core ∪ declared), and extensions define their own methods and notifications. The server side got a first-class API; the client side only had a rawextensions={...}dict for the ad. Anything behavioural (the upcoming tasks extension being the concrete case) would have needed hand-edits toClientSessionandClientinternals.A
ClientExtensiondeclares the three things a client extension can actually contribute at 2026-07-28:settings()— the capability ad, read once at construction.claims()— extratools/callresult shapes the server may substitute, each paired with the resolver that finishes it. Claims activate at version adoption (modern wires only) and the capability ad follows them: an extension whose claims are inactive is not advertised, so the client never advertises a shape it would reject. Undeclared shapes keep failing validation exactly as the spec requires.notifications()— vendor notifications to observe, delivered in order through per-binding bounded queues.Extension verbs need no registration: vendor request types subclass
mcp_types.Requestand go throughsession.send_request, whose typing now admits them. A request type can declarename_paramto mirror a params key into theMcp-Nameheader on every send path (required by extension specs such as tasks for their methods).Two session-tier escape hatches round it out:
call_tool(..., allow_claimed=True)returns the undriven claimed shape, and an unexpected claimed shape raisesUnexpectedClaimedResultcarrying the parsed value (by then the server may have durably created state, so the caller can reach it to clean up).Everything is validated at construction — identifier grammar (shared with the server via
mcp.shared.extension), claim model discriminators, cross-extension conflicts named by their owning extensions — and zero extensions means a byte-identical client: the no-extensionstools/callparse path is pinned by identity in tests.How Has This Been Tested?
Extensionsubstitutes a claimed shape; the client extension's resolver finishes it with a follow-up call), the off-switch (no extension → validation failure; legacy connection → the ad drops claim-bearing identifiers), per-request capability gating (-32021 for a non-declaring client), andMcp-Namefromname_paramobserved on the HTTP wire../scripts/testgreen at 100% line+branch coverage; pyright strict clean; full pre-commit clean.Breaking Changes
Client(extensions=...)now takes a sequence ofClientExtensioninstances instead of a dict (the dict form shipped days ago in #3003 and is pre-beta). Ad-only uses migrate toadvertise(identifier, settings); passing the old dict raises aTypeErrorthat says so. Documented indocs/migration.md.Types of changes
Checklist
Additional context
ServerNotificationis a closed union), and HTTP-modern arrival waits on thesubscriptions/listenclient runtime (Implement SEP-2575: Make MCP Stateless #2804).Mcp-Nameon task methods) are covered by existing scenarios and by the tasks suite once that extension rides this API.AI Disclaimer