Add a client extension API

Merged
MA
3mo ago
+3410-180

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 (#2891). The tasks extension's client half (#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 (#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