Skip to content

Add a client extension API#3034

Merged
maxisbey merged 15 commits into
mainfrom
client-extensions
Jun 30, 2026
Merged

Add a client extension API#3034
maxisbey merged 15 commits into
mainfrom
client-extensions

Conversation

@maxisbey

Copy link
Copy Markdown
Contributor

Adds a client-side extension API: the counterpart to the server Extension class 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 on tools/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 raw extensions={...} dict for the ad. Anything behavioural (the upcoming tasks extension being the concrete case) would have needed hand-edits to ClientSession and Client internals.

A ClientExtension declares the three things a client extension can actually contribute at 2026-07-28:

class Receipts(ClientExtension):
    identifier = "com.example/receipts"

    def claims(self) -> Sequence[ResultClaim[Any]]:
        return [ResultClaim(result_type="receipt", model=ReceiptResult, resolve=self._redeem)]

async with Client(server, extensions=[Receipts()]) as client:
    result = await client.call_tool("buy", {"item": "tea"})   # still a plain CallToolResult
  • settings() — the capability ad, read once at construction.
  • claims() — extra tools/call result 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.Request and go through session.send_request, whose typing now admits them. A request type can declare name_param to mirror a params key into the Mcp-Name header 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 raises UnexpectedClaimedResult carrying 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-extensions tools/call parse path is pinned by identity in tests.

How Has This Been Tested?

  • Unit + integration tests at every tier (construction validation, adoption/activation incl. modern→legacy→modern re-adoption, discriminated-adapter routing, notification delivery under flood, the Client fold and transparent resolution, the input_required→claimed-on-retry interaction).
  • Interaction-suite tests over the real transports: the full both-ends loop (a server Extension substitutes 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), and Mcp-Name from name_param observed on the HTTP wire.
  • Two new runnable docs tutorials exercised by the docs test suite.
  • ./scripts/test green at 100% line+branch coverage; pyright strict clean; full pre-commit clean.

Breaking Changes

Client(extensions=...) now takes a sequence of ClientExtension instances instead of a dict (the dict form shipped days ago in #3003 and is pre-beta). Ad-only uses migrate to advertise(identifier, settings); passing the old dict raises a TypeError that says so. Documented in docs/migration.md.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

  • Part of the 2026-07-28 client surface (2026-07-28 spec support — tracking #2891). The tasks extension's client half (Add the SEP-2663 Tasks extension (core) #3005) is the intended first consumer: its session/client coupling maps 1:1 onto this API and can be re-cut onto it as a follow-up.
  • End-to-end vendor-notification delivery is pinned at session tier only: there is no public server-side surface that emits vendor-method notifications yet (ServerNotification is a closed union), and HTTP-modern arrival waits on the subscriptions/listen client runtime (Implement SEP-2575: Make MCP Stateless #2804).
  • No new conformance scenario applies: this is SDK packaging surface; the wire behaviours it generalizes (per-request capability ad, Mcp-Name on task methods) are covered by existing scenarios and by the tasks suite once that extension rides this API.

AI Disclaimer

maxisbey added 12 commits June 30, 2026 15:19
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.
@maxisbey maxisbey marked this pull request as ready for review June 30, 2026 17:25
@github-actions

github-actions Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-3034.mcp-python-docs.pages.dev
Deployment https://d8391732.mcp-python-docs.pages.dev
Commit 53a538a
Triggered by @maxisbey
Updated 2026-06-30 20:02:29 UTC

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/mcp/client/session.py
),
),
_CallToolResultAdapter,
self._call_tool_adapter,

@cubic-dev-ai cubic-dev-ai Bot Jun 30, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

Comment thread docs_src/extensions/tutorial006.py
Comment thread src/mcp/client/extension.py
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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/mcp/client/session.py Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_notifyKeyError → silent drop) and that there is "no set_notification_handler analogue" — but this PR adds exactly that mechanism via NotificationBinding / 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_notifyKeyError → silent drop), and there is no set_notification_handler analogue. That half is omitted here.

    Both halves of that claim are invalidated by this PR. The KeyError branch of ClientSession._on_notify (src/mcp/client/session.py) now consults self._notification_bindings before falling back to the silent drop, delivering validated params to bound handlers through per-binding bounded FIFOs. And NotificationBinding — registered via ClientExtension.notifications() at the Client tier or ClientSession(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_request cast 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) with identifier = "com.acme/events" and notifications() returning [NotificationBinding(method="acme/searchProgress", params_type=ProgressParams, handler=on_progress)]. (2) They open Client(target, extensions=[Events()]); _fold_extensions threads the binding into ClientSession(notification_bindings=...). (3) The server emits an acme/searchProgress notification. (4) _on_notify fails the core-table parse with KeyError, looks up self._notification_bindings["acme/searchProgress"], validates the params against ProgressParams, and enqueues them; the per-binding consumer task awaits on_progress(params) in arrival order. The notification is delivered, not dropped — exactly the TS-SDK setNotificationHandler scenario the caveat says cannot be replicated. tests/client/test_session_notification_bindings.py::test_bound_vendor_notifications_are_delivered_in_order pins 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_handler setter, and that the example may still legitimately omit the notification half (there is no public server-side surface emitting vendor notifications yet — ServerNotification is 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 via NotificationBinding / ClientExtension.notifications() (see extensions/ and docs/advanced/extensions.md); this example omits that half because the lowlevel server has no surface for emitting vendor notifications yet."

Comment on lines 1 to +2
from mcp import Client
from mcp.client import advertise

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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_timereturn 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-2116-22, 53-5754-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.

Comment thread examples/stories/custom_methods/README.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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/mcp/client/extension.py Outdated
@maxisbey

maxisbey commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Responses to the review feedback, addressed in 99fec3c:

Fixed

  • ResultClaim now rejects models that don't subclass Result at runtime (the ClaimedT bound only constrains type-checked callers), and rejects models whose fields alias requestState/inputRequests: the core result surface types those optional fields, so a colliding value would fail core validation before the claim adapter ever ran. Both are construction-time errors with tests.
  • The notification ordering contract is now stated honestly in the docstring, docs page, and requirement: per-binding serialized delivery in dispatch order. Stream transports dispatch each inbound notification independently, so near-simultaneous notifications can be dispatched out of wire order. That is the pre-existing delivery property of every notification path, and the docs no longer promise more than the transport does. Enqueueing in read-loop order would mean inline dispatch (which deadlocks in-process servers) or a dispatcher-level queue; not worth it for observation-only semantics.
  • The receipts tutorial server now gates substitution with require_client_extension, so the example teaches the spec-compliant server posture; the off-by-default paragraph covers both the gate refusal (-32021) and the validation failure for ungated shapes.
  • The stale custom-methods README caveat now points at NotificationBinding (the remaining omission is server-side: there's no public surface for emitting vendor notifications yet).
  • The _build_capabilities docstring no longer overstates which identifiers drop (claim-less identifiers always advertise).

Not changed, deliberately

  • "Claimed tools/call results are rejected by core validation before the extension adapter runs": this isn't the case: claimed raws pass the 2026 surface's lenient arm, pinned by test_claimed_raw_passes_v2026_tools_call_surface_validation and exercised end-to-end by every claim test (in-memory and streamable HTTP). The one real corner was the typed-alias collision above, which is now unconstructible.
  • An unrecognized claimed resultType that also carries content parses as a complete core result. Confirmed, but it's the monolith's pre-existing open result_type + ignored-extras behavior, identical on main with zero extensions. Rejecting tags outside core plus active claims changes parsing for every client and belongs to the strict-resultType follow-up (this PR's discriminator is the mechanism that change would use). The interaction requirement now carries a note saying exactly this.
  • Vendor fields in handler-produced (non-interceptor) claimed results are stripped by the server runner's serializer. This is pre-existing, noted in the existing TODO in runner.py, and scoped to the server-side tasks work; the client-side docs already scope "verbatim" to the interceptor path.

AI Disclaimer

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.
@maxisbey

Copy link
Copy Markdown
Contributor Author

Follow-up on the latest review rounds, addressed in 53a538a:

Fixed

  • The reserved-key check on claim models now collects every alias form: plain alias, validation_alias (including AliasChoices members and the head of an AliasPath), and serialization_alias. A model routing requestState or inputRequests through any of those spellings is rejected at construction, with tests for each route.

Already fixed at the reviewed commit

  • "Core surface pre-validation rejects active claims whose vendor fields alias requestState/inputRequests": the construction-time reservation landed in 99fec3c and the exact scenario (a requestState-aliased dict field) raises a ValueError there; 53a538a extends it to the remaining alias spellings. On the _meta variant: a subclass that overrides meta loses the _meta alias (the alias generator yields meta), so an override cannot collide with the surface's typed _meta field. A non-dict under _meta on the wire would be malformed core plumbing from the server, not a claim-shape issue.

Standing as previously stated

  • Unknown tag plus content parsing as a complete core result: pre-existing open-tag behavior of the monolith, identical on main with zero extensions; rejecting tags outside core plus active claims changes parsing for every client and is the strict-resultType follow-up. The interaction requirement carries a note to that effect.
  • Vendor fields stripped from handler-produced claimed results: pre-existing server-side serializer behavior, anchored by the existing TODO in runner.py and scoped to the server-side tasks work.

AI Disclaimer

@maxisbey maxisbey merged commit 4df6091 into main Jun 30, 2026
38 checks passed
@maxisbey maxisbey deleted the client-extensions branch June 30, 2026 20:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants