Add a client extension API (#3034)

This commit is contained in:
Max
2026-06-30 21:31:02 +01:00
committed by GitHub
parent 7322ca56f4
commit 4df609119f
37 changed files with 3410 additions and 180 deletions
+108 -18
View File
@@ -2,9 +2,10 @@
An **extension** is an opt-in bundle of MCP behaviour behind one identifier.
It can contribute tools, resources, and new request methods, and it can wrap `tools/call`.
The server advertises it under `capabilities.extensions`, the client opts in the same way,
and nothing changes for anyone who didn't ask for it. That is the contract ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)), and
On a server it can contribute tools, resources, and new request methods, and it can wrap
`tools/call`. On a client it can claim extra `tools/call` result shapes and observe vendor
notifications. Each side advertises under its own `capabilities.extensions`, and nothing
changes for anyone who didn't ask for it. That is the contract ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)), and
it has one golden rule: **extensions are off by default**.
## Using an extension
@@ -79,7 +80,7 @@ And `main()` is the proof, an in-memory client straight against `mcp`:
An extension can register **new request methods**: its own verbs, served next to the
spec's:
```python title="server.py" hl_lines="15-21 30 39-47"
```python title="server.py" hl_lines="16-22 31 40-48"
--8<-- "docs_src/extensions/tutorial004.py"
```
@@ -108,19 +109,19 @@ runtime:
The same file's `main()` is the whole client story, both halves of it:
```python title="server.py" hl_lines="53-57"
```python title="server.py" hl_lines="54-58"
--8<-- "docs_src/extensions/tutorial004.py"
```
* `Client(..., extensions={EXTENSION_ID: {}})` declares the extension. That map
becomes `ClientCapabilities.extensions`: on a 2026-07-28 connection it travels in
the per-request `_meta` envelope, so the server sees it on **every** request; on
a legacy connection it rides the `initialize` handshake. Server code doesn't care
which: `require_client_extension(ctx, ...)` and
* `Client(..., extensions=[advertise(EXTENSION_ID)])` declares the extension. The
declarations become `ClientCapabilities.extensions`: on a 2026-07-28 connection
the map travels in the per-request `_meta` envelope, so the server sees it on
**every** request; on a legacy connection it rides the `initialize` handshake.
Server code doesn't care which: `require_client_extension(ctx, ...)` and
`ctx.session.check_client_capability(...)` read the right source on both paths.
* Vendor methods drop one layer to `client.session.send_request(...)`; `Client`
only grows first-class methods for spec verbs. The `cast` is there because
`send_request` is typed against the spec's closed request union.
only grows first-class methods for spec verbs. `send_request` accepts any
`Request` subclass, so the vendor request passes as-is.
### Intercepting `tools/call`
@@ -144,15 +145,104 @@ or veto a tool call:
The hook wraps `tools/call` and nothing else. For every-message concerns, use
[Middleware](middleware.md). That is what it is for.
## Using a client extension
A **client extension** is the same contract from the consuming side: a bundle of
client-side behaviour behind one identifier. Pass instances to
`Client(extensions=[...])` and call tools normally:
```python title="client.py" hl_lines="67-69"
--8<-- "docs_src/extensions/tutorial006.py"
```
`call_tool("buy", ...)` returns a plain `CallToolResult`, like every other call. What
the extension changed: the server may now answer `buy` with a `receipt` **result
shape** instead of a final result, and `Receipts` finishes it (here by redeeming the
receipt with a follow-up call) before `call_tool` returns. Nothing about the call
site moves.
Drop the extension and none of this exists: the server's gate refuses a client
that did not declare it (error -32021), and a claimed shape from a server that
skips the gate fails validation, exactly as the spec requires for an
unrecognized `resultType`. Off by default, on both ends of the wire.
To advertise an identifier with **no** client-side behaviour (the server gates on
the capability, the client does nothing, as in the search client above), use
`advertise()`:
```python
from mcp.client import advertise
client = Client(mcp, extensions=[advertise("com.example/search")])
```
## Writing a client extension
Subclass `ClientExtension` and override only what you need. Three contribution
kinds, each with a default: `settings()`, `claims()`, and `notifications()`.
```python title="client.py" hl_lines="18-19 44-45 47-48"
--8<-- "docs_src/extensions/tutorial006.py"
```
* The identifier follows the same grammar as the server's, validated when the class
is defined.
* `claims()` returns `ResultClaim`s: a wire tag, the model that parses it, and the
resolver that finishes it. The model must pin the tag with
`result_type: Literal["receipt"]` and must not subclass the verb's core result
types; both are enforced when the claim is constructed. Vendor fields like
`receipt_token` ride the wire as-is: a substituted shape reaches the client
verbatim.
* The resolver receives the parsed model and a `ClaimContext`; `ctx.session` is the
same public handle as `client.session`, so follow-ups are ordinary session calls.
It returns the verb's normal `CallToolResult`.
* `settings()` is the value advertised at `ClientCapabilities.extensions[identifier]`,
read once at `Client` construction.
`notifications()` declares vendor server notifications to observe:
```python
def notifications(self) -> Sequence[NotificationBinding[Any]]:
return [NotificationBinding(method="notifications/receipts", params_type=ReceiptEvent, handler=self.on_receipt)]
```
The handler receives validated params one at a time, in dispatch order. It observes; it cannot veto
or reply.
Two quiet rules. Claims are active on 2026-07-28 connections only, and the capability
ad follows them: on a legacy connection the claims dissolve and the identifier drops
out of the ad with them, so the client never advertises an extension whose shapes it
would reject. And when you want the claimed shape yourself instead of the resolver,
call `client.session.call_tool(..., allow_claimed=True)`; without that flag, a
claimed shape reaching a session-tier caller raises `UnexpectedClaimedResult`.
### Extension verbs
An extension's own request methods need no client-side registration. A vendor request
type subclasses `mcp_types.Request` and goes through `client.session.send_request`,
as in [Serving your own methods](#serving-your-own-methods). One addition: when a
params key must ride the `Mcp-Name` header (extension specs such as tasks require
this for their verbs), the request type declares `name_param`:
```python title="client.py" hl_lines="23-26 47-48"
--8<-- "docs_src/extensions/tutorial007.py"
```
The session mirrors `params["jobId"]` into `Mcp-Name` on every send path, and a
missing value fails loudly rather than silently omitting a required header.
## What an extension cannot do
The contribution surface is **closed** on purpose: settings, tools, resources,
methods, one `tools/call` interceptor. An extension cannot:
The contribution surface is **closed** on purpose. On the server: settings, tools,
resources, methods, one `tools/call` interceptor. On the client: settings, result
claims, notification bindings. An extension cannot:
* **Reach into the server.** It declares data; it holds no server reference.
* **Replace core behaviour.** Spec methods are rejected at construction, and
`initialize` is reserved by the runner outright.
* **Register late.** After `MCPServer(...)` returns, the extension set is what it is.
* **Reach into the host.** It declares data; it holds no server or client reference.
* **Replace core behaviour.** Spec methods and core result tags are rejected at
construction (`initialize` is reserved by the runner outright); a notification
binding shadowed by core vocabulary goes quiet with a warning instead.
* **Register late.** After `MCPServer(...)` or `Client(...)` returns, the extension
set is what it is.
If you are fighting these walls, you are not writing an extension. You are writing
a fork. The walls are the feature: a user reading `extensions=[Apps(), Stamps()]`
+36 -5
View File
@@ -469,11 +469,42 @@ extension handler can call `mcp.server.mcpserver.require_client_extension(ctx, i
to reject a request with the `-32021` (missing required client capability) error
when the client did not declare the extension.
Clients advertise extension support with the new `Client(extensions=...)` /
`ClientSession(extensions=...)` argument, mirrored into `ClientCapabilities.extensions`.
The extensions capability map is negotiated over `server/discover` (modern path);
a legacy `initialize` handshake does not carry it. Extensions are off by default
and never alter behaviour unless registered.
On the client, `Client(extensions=...)` takes a sequence of
`mcp.client.ClientExtension` instances. A client extension contributes its
capability ad (mirrored into `ClientCapabilities.extensions`), its result
claims (extra `tools/call` result shapes that `Client.call_tool` resolves
transparently through the claim's resolver), and its notification bindings
(handlers for vendor server notifications). The capability map rides
`server/discover` and every modern request's `_meta` envelope; a legacy
`initialize` handshake carries only the claim-less identifiers, since claimed
result shapes cannot be delivered on a legacy wire. Extensions are off by
default and never alter behaviour unless registered. (The low-level
`ClientSession(extensions=...)` keeps the raw identifier-to-settings dict.)
Changed in the v2 pre-releases: earlier alphas took
`Client(extensions={identifier: settings})`, an advertisement-only dict.
Extensions now contribute behaviour (claims and notification handlers), not
just an ad, so the argument is a sequence of declaration objects. An ad-only
entry becomes an `advertise()` call:
**Before (v2 alphas):**
```python
client = Client(server, extensions={"com.example/ui": {"mimeTypes": [...]}})
```
**After:**
```python
from mcp.client import advertise
client = Client(server, extensions=[advertise("com.example/ui", {"mimeTypes": [...]})])
```
`advertise()` is only for identifiers with no client-side behaviour. For a
behavioural extension (e.g. tasks, once its extension ships), construct that
extension's object instead; advertising an identifier you do not implement
asserts wire support you don't have.
### `McpError` renamed to `MCPError`
+2 -1
View File
@@ -1,4 +1,5 @@
from mcp import Client
from mcp.client import advertise
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID, Apps, client_supports_apps
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.context import Context
@@ -32,7 +33,7 @@ mcp = MCPServer("clock", extensions=[apps])
async def main() -> None:
async with Client(mcp, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as client:
async with Client(mcp, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]) as client:
result = await client.call_tool("get_time", {})
print(result.content)
# [TextContent(text='2026-06-26T12:00:00Z')]
+4 -3
View File
@@ -1,10 +1,11 @@
from collections.abc import Sequence
from typing import Any, Literal, cast
from typing import Any, Literal
import mcp_types as types
from pydantic import Field
from mcp import Client
from mcp.client import advertise
from mcp.server.context import ServerRequestContext
from mcp.server.extension import Extension, MethodBinding
from mcp.server.mcpserver import MCPServer, require_client_extension
@@ -51,8 +52,8 @@ mcp = MCPServer("catalog", extensions=[Search()])
async def main() -> None:
async with Client(mcp, extensions={EXTENSION_ID: {}}) as client:
async with Client(mcp, extensions=[advertise(EXTENSION_ID)]) as client:
request = SearchRequest(params=SearchParams(query="mcp", limit=3))
result = await client.session.send_request(cast("types.ClientRequest", request), SearchResult)
result = await client.session.send_request(request, SearchResult)
print(result.items)
# ['mcp-0', 'mcp-1', 'mcp-2']
+70
View File
@@ -0,0 +1,70 @@
from collections.abc import Sequence
from typing import Any, Literal
import mcp_types as types
from mcp import Client
from mcp.client import ClaimContext, ClientExtension, ResultClaim
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
from mcp.server.extension import Extension
from mcp.server.mcpserver import MCPServer, require_client_extension
EXTENSION_ID = "com.example/receipts"
class ReceiptResult(types.Result):
"""The claimed result shape; `result_type` pins the wire tag."""
result_type: Literal["receipt"] = "receipt"
receipt_token: str
class ReceiptIssuer(Extension):
"""Server half: answers `buy` with a receipt instead of a final result."""
identifier = EXTENSION_ID
async def intercept_tool_call(
self,
params: types.CallToolRequestParams,
ctx: ServerRequestContext[Any, Any],
call_next: CallNext,
) -> HandlerResult:
if params.name != "buy":
return await call_next(ctx)
require_client_extension(ctx, EXTENSION_ID)
return {"resultType": "receipt", "receiptToken": "r-117"}
class Receipts(ClientExtension):
"""Client half: claims the `receipt` shape and supplies the code that finishes it."""
identifier = EXTENSION_ID
def claims(self) -> Sequence[ResultClaim[Any]]:
return [ResultClaim(result_type="receipt", model=ReceiptResult, resolve=self._redeem)]
async def _redeem(self, claimed: ReceiptResult, ctx: ClaimContext) -> types.CallToolResult:
return await ctx.session.call_tool("redeem", {"token": claimed.receipt_token})
mcp = MCPServer("shop", extensions=[ReceiptIssuer()])
@mcp.tool()
def buy(item: str) -> types.CallToolResult:
"""Buy an item."""
raise NotImplementedError # ReceiptIssuer answers `buy` before the tool runs
@mcp.tool()
def redeem(token: str) -> str:
"""Exchange a receipt token for the goods."""
return f"goods for {token}"
async def main() -> None:
async with Client(mcp, extensions=[Receipts()]) as client:
result = await client.call_tool("buy", {"item": "lamp"})
print(result.content)
# [TextContent(text='goods for r-117')]
+50
View File
@@ -0,0 +1,50 @@
from collections.abc import Sequence
from typing import Any, Literal
import mcp_types as types
from mcp import Client
from mcp.client import advertise
from mcp.server.context import ServerRequestContext
from mcp.server.extension import Extension, MethodBinding
from mcp.server.mcpserver import MCPServer
EXTENSION_ID = "com.example/jobs"
class JobParams(types.RequestParams):
job_id: str
class JobStatus(types.Result):
status: str
class JobStatusRequest(types.Request[JobParams, Literal["com.example/jobs.status"]]):
method: Literal["com.example/jobs.status"] = "com.example/jobs.status"
params: JobParams
name_param = "jobId" # params["jobId"] rides the Mcp-Name header
async def job_status(ctx: ServerRequestContext[Any, Any], params: JobParams) -> JobStatus:
return JobStatus(status=f"{params.job_id} is running")
class Jobs(Extension):
"""An extension whose verb names its subject, so the header can route on it."""
identifier = EXTENSION_ID
def methods(self) -> Sequence[MethodBinding]:
return [MethodBinding("com.example/jobs.status", JobParams, job_status)]
mcp = MCPServer("worker", extensions=[Jobs()])
async def main() -> None:
async with Client(mcp, extensions=[advertise(EXTENSION_ID)]) as client:
request = JobStatusRequest(params=JobParams(job_id="job-7"))
result = await client.session.send_request(request, JobStatus)
print(result.status)
# job-7 is running
+1 -1
View File
@@ -26,7 +26,7 @@ uv run python -m stories.apps.client --http
`text/html;profile=mcp-app`.
- `server.py` `client_supports_apps(ctx)` — SEP-2133 graceful degradation: a
client that did not negotiate Apps gets a text-only result.
- `client.py` `Client(target, extensions={...})` — the client advertises Apps
- `client.py` `Client(target, extensions=[advertise(...)])` — the client advertises Apps
support so the server returns the UI-enabled result, then reads the tool's
`_meta.ui.resourceUri` and fetches that resource.
+4 -2
View File
@@ -2,7 +2,7 @@
from mcp_types import TextContent, TextResourceContents
from mcp.client import Client
from mcp.client import Client, advertise
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID
from stories._harness import Target, run_client
@@ -10,7 +10,9 @@ from stories._harness import Target, run_client
async def main(target: Target, *, mode: str = "auto") -> None:
# Advertise MCP Apps support so the server returns the UI-enabled result; a
# client that omits this gets the text-only fallback (graceful degradation).
async with Client(target, mode=mode, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as client:
async with Client(
target, mode=mode, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]
) as client:
# The extensions capability map rides `server/discover` (modern only). On a
# legacy connection (today's stdio) it is absent, so assert it only when present.
if client.server_capabilities.extensions is not None:
+6 -8
View File
@@ -28,18 +28,16 @@ uv run python -m stories.custom_methods.client --http
method string is the wire `method`; use a vendor prefix so it can never
collide with a future spec method.
- `client.py` `client.session.send_request(...)``Client` only exposes spec
verbs, so vendor methods go through the underlying `ClientSession`. The
`cast("types.ClientRequest", ...)` is needed because `send_request`'s
`request` parameter is currently typed as the closed spec union; widening it
(or adding `Client.send_request`) is tracked for beta.
verbs, so vendor methods go through the underlying `ClientSession`.
`send_request` accepts any `types.Request` subclass.
## Caveats
- The TypeScript SDK's equivalent example also shows a custom server→client
**notification** (`acme/searchProgress`). The Python client currently drops
any notification whose method is not in the spec registry
(`ClientSession._on_notify``KeyError` → silent drop), and there is no
`set_notification_handler` analogue. That half is omitted here.
**notification** (`acme/searchProgress`). The Python client can observe
vendor notifications via `NotificationBinding` (see
`docs/advanced/extensions.md`). That half is omitted here because the
lowlevel server has no surface for emitting vendor notifications yet.
## Spec
+4 -6
View File
@@ -1,6 +1,6 @@
"""Send a vendor-prefixed request via the `client.session` escape hatch."""
from typing import Literal, cast
from typing import Literal
import mcp_types as types
@@ -26,12 +26,10 @@ async def main(target: Target, *, mode: str = "auto") -> None:
async with Client(target, mode=mode) as client:
# `Client` only exposes spec-defined verbs, so vendor methods have to drop one
# layer to `client.session` today — there is no `Client`-level API for them
# yet, and whether `.session` stays public is undecided. `send_request` is
# typed against the closed `ClientRequest` union, hence the cast; at runtime
# the body only calls `.model_dump()` and the unknown method skips the
# per-spec result-validation registry.
# yet, and whether `.session` stays public is undecided. `send_request`
# accepts any `Request` subclass.
request = SearchRequest(params=SearchParams(query="mcp", limit=3))
result = await client.session.send_request(cast("types.ClientRequest", request), SearchResult)
result = await client.session.send_request(request, SearchResult)
assert result.items == ["mcp-0", "mcp-1", "mcp-2"], result
+1 -1
View File
@@ -24,7 +24,7 @@ uv run python -m stories.extensions.client --http
rejects clients that did not declare the extension with `-32021` (missing
required client capability) and a machine-readable `requiredCapabilities`
payload.
- `client.py` `Client(target, extensions={EXTENSION_ID: {}})` — the client-side
- `client.py` `Client(target, extensions=[advertise(EXTENSION_ID)])` — the client-side
half of the negotiation; on 2026-07-28 it travels in the per-request `_meta`
envelope.
- `client.py` `client.session.send_request(...)` — vendor methods have no
+5 -6
View File
@@ -1,11 +1,11 @@
"""Discover an extension's capability entry, call its tool, then send its vendor method."""
from typing import Literal, cast
from typing import Literal
import mcp_types as types
from mcp_types import TextContent
from mcp.client import Client
from mcp.client import Client, advertise
from stories._harness import Target, run_client
EXTENSION_ID = "com.example/catalog"
@@ -28,7 +28,7 @@ class SearchResult(types.Result):
async def main(target: Target, *, mode: str = "auto") -> None:
# Declare the extension client-side so the server's `require_client_extension`
# gate on `com.example/search` passes.
async with Client(target, mode=mode, extensions={EXTENSION_ID: {}}) as client:
async with Client(target, mode=mode, extensions=[advertise(EXTENSION_ID)]) as client:
# The extensions capability map rides `server/discover` (modern only). On a
# legacy connection it is absent, so assert it only when present.
if client.server_capabilities.extensions is not None:
@@ -43,10 +43,9 @@ async def main(target: Target, *, mode: str = "auto") -> None:
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "mcp-suggestion", result.content[0].text
# Vendor methods drop one layer to `client.session` (see custom_methods/);
# the cast is needed because `send_request` is typed against the spec union.
# Vendor methods drop one layer to `client.session` (see custom_methods/).
request = SearchRequest(params=SearchParams(query="mcp", limit=3))
found = await client.session.send_request(cast("types.ClientRequest", request), SearchResult)
found = await client.session.send_request(request, SearchResult)
assert found.items == ["mcp-0", "mcp-1", "mcp-2"], found
+2
View File
@@ -8,6 +8,7 @@ https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/dr
from mcp_types._types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
CORE_RESULT_TYPES,
DEFAULT_NEGOTIATED_VERSION,
LOG_LEVEL_META_KEY,
PROTOCOL_VERSION_META_KEY,
@@ -231,6 +232,7 @@ __all__ = [
"CLIENT_CAPABILITIES_META_KEY",
"LOG_LEVEL_META_KEY",
# Type aliases and variables
"CORE_RESULT_TYPES",
"ContentBlock",
"ElicitRequestedSchema",
"ElicitRequestParams",
+13 -2
View File
@@ -8,7 +8,7 @@ the negotiated version. Per-field docstrings note version availability. The
from __future__ import annotations
from typing import Annotated, Any, Final, Generic, Literal, TypeAlias, TypeVar
from typing import Annotated, Any, ClassVar, Final, Generic, Literal, TypeAlias, TypeVar, get_args
from pydantic import (
BaseModel,
@@ -128,6 +128,12 @@ class Request(MCPModel, Generic[RequestParamsT, MethodT]):
method: MethodT
params: RequestParamsT
name_param: ClassVar[str | None] = None
"""Wire-params key mirrored into the `Mcp-Name` header on sends; SEP-2663 requires it for tasks/*.
Subclasses override by bare assignment: re-annotating as `ClassVar` trips pyright's invariance check.
"""
class PaginatedRequest(Request[PaginatedRequestParams | None, MethodT], Generic[MethodT]):
"""Base class for paginated requests, matching the schema's PaginatedRequest interface."""
@@ -144,7 +150,9 @@ class Notification(MCPModel, Generic[NotificationParamsT, MethodT]):
params: NotificationParamsT
ResultType = Literal["complete", "input_required"] | str
_CoreResultType = Literal["complete", "input_required"]
ResultType = _CoreResultType | str
"""Tags a `Result` so the client knows how to parse it (2026-07-28).
"complete" means the result is final; "input_required" means it is an
@@ -152,6 +160,9 @@ ResultType = Literal["complete", "input_required"] | str
Absent `resultType` is equivalent to "complete".
"""
CORE_RESULT_TYPES: Final[frozenset[str]] = frozenset(get_args(_CoreResultType))
"""The `resultType` tags owned by the core protocol vocabulary; extension claims may not re-key them."""
class Result(MCPModel):
"""Base class for JSON-RPC results.
+14
View File
@@ -12,6 +12,14 @@ from mcp.client.caching import (
)
from mcp.client.client import Client
from mcp.client.context import ClientRequestContext
from mcp.client.extension import (
ClaimContext,
ClientExtension,
NotificationBinding,
ResultClaim,
UnexpectedClaimedResult,
advertise,
)
from mcp.client.session import ClientSession
__all__ = [
@@ -19,11 +27,17 @@ __all__ = [
"CacheEntry",
"CacheKey",
"CacheMode",
"ClaimContext",
"Client",
"ClientExtension",
"ClientRequestContext",
"ClientSession",
"InMemoryResponseCacheStore",
"InputRequiredRoundsExceededError",
"NotificationBinding",
"ResponseCacheStore",
"ResultClaim",
"Transport",
"UnexpectedClaimedResult",
"advertise",
]
+104 -8
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import hashlib
import logging
import uuid
from collections.abc import Awaitable, Callable, Mapping
from collections.abc import Awaitable, Callable, Mapping, Sequence
from contextlib import AsyncExitStack
from dataclasses import KW_ONLY, dataclass, field
from typing import Any, Literal, TypeVar, cast
@@ -36,6 +36,7 @@ from mcp_types import (
ReadResourceResult,
RequestParamsMeta,
ResourceTemplateReference,
Result,
ServerCapabilities,
)
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS
@@ -46,6 +47,7 @@ from mcp.client._memory import InMemoryTransport
from mcp.client._probe import negotiate_auto
from mcp.client._transport import Transport
from mcp.client.caching import CacheConfig, CacheMode, ClientResponseCache, InMemoryResponseCacheStore
from mcp.client.extension import ClaimContext, ClientExtension, NotificationBinding, ResultClaim
from mcp.client.session import (
ClientRequestContext,
ClientSession,
@@ -62,6 +64,7 @@ from mcp.server.runner import modern_on_request
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
from mcp.shared.dispatcher import Dispatcher, ProgressFnT
from mcp.shared.exceptions import MCPDeprecationWarning, MCPError
from mcp.shared.extension import validate_extension_identifier
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.session import RequestResponder
@@ -188,6 +191,72 @@ async def _no_inbound_client_notifications(_dctx: Any, _method: str, _params: Ma
"""
@dataclass(frozen=True)
class _FoldedExtensions:
"""`Client.extensions` instances folded into the shapes `ClientSession` consumes."""
ad: dict[str, dict[str, Any]] | None
claims: dict[str, tuple[ResultClaim[Any], ...]] | None
bindings: tuple[NotificationBinding[Any], ...] | None
by_model: Mapping[type[Result], ResultClaim[Any]]
def _fold_extensions(extensions: Sequence[ClientExtension] | None) -> _FoldedExtensions:
"""Fold extension contributions at construction, naming both owners on duplicate tags or methods."""
if isinstance(extensions, Mapping):
raise TypeError(
"extensions= takes a sequence of ClientExtension instances. The mapping form was "
"replaced: use advertise(identifier, settings) for advertise-only entries"
)
if not extensions:
return _FoldedExtensions(ad=None, claims=None, bindings=None, by_model={})
ad: dict[str, dict[str, Any]] = {}
claims: dict[str, tuple[ResultClaim[Any], ...]] = {}
bindings: list[NotificationBinding[Any]] = []
by_model: dict[type[Result], ResultClaim[Any]] = {}
claim_owners: dict[str, str] = {}
binding_owners: dict[str, str] = {}
for extension in extensions:
identifier = getattr(extension, "identifier", None)
if identifier is None:
raise ValueError(
f"{type(extension).__name__} has no `identifier`; a ClientExtension must set the "
"`identifier` class attribute (or assign one in `__init__`) before it can be used"
)
validate_extension_identifier(identifier, owner=type(extension).__name__)
if identifier in ad:
raise ValueError(f"extension identifier {identifier!r} is passed more than once")
ad[identifier] = extension.settings()
extension_claims = tuple(extension.claims())
for claim in extension_claims:
tag = claim.result_type
if tag in claim_owners:
owner = claim_owners[tag]
both = (
f"extension {identifier!r} claims"
if owner == identifier
else (f"extensions {owner!r} and {identifier!r} both claim")
)
raise ValueError(f"{both} resultType {tag!r}; a wire tag can have only one resolver")
claim_owners[tag] = identifier
# Each model pins its result_type Literal to one tag, so this index cannot collide.
by_model[claim.model] = claim
if extension_claims:
claims[identifier] = extension_claims
for binding in extension.notifications():
if binding.method in binding_owners:
owner = binding_owners[binding.method]
both = (
f"extension {identifier!r} binds"
if owner == identifier
else (f"extensions {owner!r} and {identifier!r} both bind")
)
raise ValueError(f"{both} notification method {binding.method!r}; a method can have only one observer")
binding_owners[binding.method] = identifier
bindings.append(binding)
return _FoldedExtensions(ad=ad, claims=claims or None, bindings=tuple(bindings) or None, by_model=by_model)
@dataclass
class Client:
"""A high-level MCP client for connecting to MCP servers.
@@ -268,9 +337,12 @@ class Client:
`read_resource` give up. Use `client.session.<method>(..., allow_input_required=True)`
to drive the loop manually instead."""
extensions: dict[str, dict[str, Any]] | None = None
"""SEP-2133 extension support to advertise under `ClientCapabilities.extensions`
(identifier -> settings), e.g. `{"io.modelcontextprotocol/ui": {"mimeTypes": [...]}}`."""
extensions: Sequence[ClientExtension] | None = None
"""Opt-in client extensions (SEP-2133).
Each instance contributes its capability ad, its result claims (resolved
transparently by `call_tool`), and its notification bindings. For an
ad-only entry use `mcp.client.advertise(identifier, settings)`."""
cache: CacheConfig | Literal[False] | None = None
"""Client-side response caching for the SEP-2549 cacheable methods (2026-07-28).
@@ -286,6 +358,7 @@ class Client:
_exit_stack: AsyncExitStack | None = field(init=False, default=None)
_connect: _Connector = field(init=False, repr=False, compare=False)
_response_cache: ClientResponseCache | None = field(init=False, default=None, repr=False, compare=False)
_folded_extensions: _FoldedExtensions = field(init=False, repr=False, compare=False)
def __post_init__(self) -> None:
if self.mode not in ("legacy", "auto") and self.mode not in MODERN_PROTOCOL_VERSIONS:
@@ -298,6 +371,8 @@ class Client:
f"mode must be 'legacy', 'auto', or one of {list(MODERN_PROTOCOL_VERSIONS)}; got {self.mode!r}{hint}"
)
self._folded_extensions = _fold_extensions(self.extensions)
srv = self.server
if isinstance(srv, MCPServer):
srv = srv._lowlevel_server # pyright: ignore[reportPrivateUsage]
@@ -348,7 +423,9 @@ class Client:
message_handler=message_handler,
client_info=self.client_info,
elicitation_callback=self.elicitation_callback,
extensions=self.extensions,
extensions=self._folded_extensions.ad,
result_claims=self._folded_extensions.claims,
notification_bindings=self._folded_extensions.bindings,
)
async def __aenter__(self) -> Client:
@@ -613,6 +690,11 @@ class Client:
state is still subject to the server's TTL, request binding, and key
lifetime; a server on the default process-local key rejects it after a restart.
Result shapes claimed by this client's `extensions` are finished by the
owning claim's resolver, whose `CallToolResult` is returned; resolver
exceptions propagate as-is. To receive the claimed shape yourself, use
`client.session.call_tool(..., allow_claimed=True)`.
Args:
name: The name of the tool to call.
arguments: Arguments to pass to the tool.
@@ -631,7 +713,7 @@ class Client:
MCPError: A callback returned `ErrorData` for an embedded input request.
"""
async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | InputRequiredResult:
async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | InputRequiredResult | Result:
return await self.session.call_tool(
name,
arguments,
@@ -641,9 +723,23 @@ class Client:
request_state=s,
meta=meta,
allow_input_required=True,
# Input rounds resolve before a claimed result, so a claim may end any round.
allow_claimed=True,
)
return await self._drive_input_required(await retry(input_responses, request_state), retry)
result = await self._drive_input_required(await retry(input_responses, request_state), retry)
if isinstance(result, CallToolResult):
return result
# Only claimed shapes reach this point, so the lookup is total.
claim = self._folded_extensions.by_model[type(result)]
final = await claim.resolve(
result,
ClaimContext(session=self.session, tool_name=name, read_timeout_seconds=read_timeout_seconds),
)
if not final.is_error:
# Match the direct path: revalidate the output schema, but never for isError results.
await self.session.validate_tool_result(name, final)
return final
async def list_prompts(
self,
@@ -717,7 +813,7 @@ class Client:
async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
ctx = ClientRequestContext(session=session, request_id=key, meta=req.params.meta if req.params else None)
return await session._dispatch_input_request(ctx, req) # pyright: ignore[reportPrivateUsage]
return await session.dispatch_input_request(ctx, req)
return await run_input_required_driver(
first, dispatch=dispatch, retry=retry, max_rounds=self.input_required_max_rounds
+196
View File
@@ -0,0 +1,196 @@
"""Opt-in extension interface for MCP clients.
Subclass `ClientExtension`, set `identifier`, override the hooks you need, and
pass instances to `Client(extensions=[...])`. For an identifier-only
capability ad, use `advertise()`.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Final, Generic, Literal, TypeVar, get_args
from mcp_types import CORE_RESULT_TYPES, CallToolResult, InputRequiredResult, Result
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from pydantic import AliasChoices, AliasPath, BaseModel
from pydantic.fields import FieldInfo
from mcp.shared.extension import validate_extension_identifier
if TYPE_CHECKING:
from mcp.client.session import ClientSession
__all__ = [
"ClaimContext",
"ClientExtension",
"NotificationBinding",
"ResultClaim",
"UnexpectedClaimedResult",
"advertise",
]
_CLAIM_METHODS: Final[frozenset[str]] = frozenset({"tools/call"})
"""The closed set of verbs a claim may attach to; widen together with the `method` Literal."""
_RESERVED_WIRE_ALIASES: Final[frozenset[str]] = frozenset({"requestState", "inputRequests"})
"""Typed optional fields of the core result surface that pre-validates every inbound result."""
def _wire_keys(name: str, field: FieldInfo) -> frozenset[str]:
"""Every top-level wire key this field can read from or write to."""
keys = {field.alias or name}
if field.serialization_alias:
keys.add(field.serialization_alias)
validation_alias = field.validation_alias
choices = validation_alias.choices if isinstance(validation_alias, AliasChoices) else [validation_alias]
for choice in choices:
if isinstance(choice, AliasPath):
choice = choice.path[0]
if isinstance(choice, str):
keys.add(choice)
return frozenset(keys)
ClaimedT = TypeVar("ClaimedT", bound=Result)
NotifyParamsT = TypeVar("NotifyParamsT", bound=BaseModel)
@dataclass(frozen=True, kw_only=True)
class ClaimContext:
"""Host-injected context for one `ResultClaim.resolve` call."""
session: ClientSession
tool_name: str
read_timeout_seconds: float | None
@dataclass(frozen=True, kw_only=True)
class ResultClaim(Generic[ClaimedT]):
"""One extra result shape on one spec verb, keyed by the wire `resultType`.
Active only while the declaring extension is constructed into the client and
the negotiated protocol version admits it. `resolve` finishes a claimed
result, may send follow-ups through `ctx.session`, and must return the
verb's ordinary result. All field constraints are enforced at construction.
"""
result_type: str
model: type[ClaimedT]
resolve: Callable[[ClaimedT, ClaimContext], Awaitable[CallToolResult]]
method: Literal["tools/call"] = "tools/call"
protocol_versions: frozenset[str] | None = None
def __post_init__(self) -> None:
if self.method not in _CLAIM_METHODS:
raise ValueError(f"claims attach to {sorted(_CLAIM_METHODS)} only; got method {self.method!r}")
if self.result_type in CORE_RESULT_TYPES:
raise ValueError(f"resultType {self.result_type!r} is core protocol vocabulary")
if Result not in self.model.__mro__: # runtime guard; the ClaimedT bound only constrains checked callers
raise ValueError(f"{self.model.__name__} must subclass mcp_types.Result")
if issubclass(self.model, CallToolResult | InputRequiredResult):
raise ValueError("claim models must not subclass core result types")
for name, model_field in self.model.model_fields.items():
for clash in sorted(_wire_keys(name, model_field) & _RESERVED_WIRE_ALIASES):
raise ValueError(
f"{self.model.__name__}.{name} aliases {clash!r}, a typed field of the core "
"result surface; a colliding value would fail core validation before the "
"claim adapter runs"
)
field = self.model.model_fields.get("result_type")
if field is None or get_args(field.annotation) != (self.result_type,):
raise ValueError(f"{self.model.__name__}.result_type must be Literal[{self.result_type!r}]")
if self.protocol_versions is not None and not self.protocol_versions:
raise ValueError("empty protocol_versions could never activate; use None for all")
if self.protocol_versions is not None and not self.protocol_versions.issubset(MODERN_PROTOCOL_VERSIONS):
unrecognized = sorted(self.protocol_versions.difference(MODERN_PROTOCOL_VERSIONS))
raise ValueError(
f"protocol_versions {unrecognized} are not modern protocol revisions; claimed shapes "
"cannot be delivered on a legacy wire (None means every modern version)"
)
class UnexpectedClaimedResult(RuntimeError):
"""A claimed (extension) result arrived on a `call_tool` that did not opt in.
The parsed value is carried as `result`; the server may already hold state it
references. Opt in via `Client(extensions=[...])` or `allow_claimed=True`.
"""
def __init__(self, result: Result) -> None:
super().__init__(
f"Server returned a claimed result ({type(result).__name__}); pass the owning extension to "
"Client(extensions=[...]) for transparent resolution, or call with allow_claimed=True "
"and handle the shape. The carried result may reference server-side state needing cleanup."
)
self.result = result
@dataclass(frozen=True, kw_only=True)
class NotificationBinding(Generic[NotifyParamsT]):
"""Deliver server notifications for `method` (the bare wire name) to `handler`.
Observation-only: validated params arrive one at a time per binding, in
dispatch order, through a bounded queue that drops the oldest with a warning
on overflow. Stream transports dispatch each notification independently, so
near-simultaneous notifications may be dispatched out of wire order. Methods
the negotiated version's core tables handle are never delivered to bindings.
"""
method: str
params_type: type[NotifyParamsT]
handler: Callable[[NotifyParamsT], Awaitable[None]]
class ClientExtension:
"""Base class for an opt-in client extension; override only what you need.
The surface is declarative, fixed at construction, and never receives the client.
"""
#: Reverse-DNS extension identifier, advertised under `ClientCapabilities.extensions`.
identifier: str
def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
# Per-instance identifiers (assigned in __init__) are validated at consumption instead.
if (identifier := cls.__dict__.get("identifier")) is not None:
validate_extension_identifier(identifier, owner=cls.__name__)
def settings(self) -> dict[str, Any]:
"""Per-extension settings advertised at `ClientCapabilities.extensions[identifier]`.
Read once at `Client` construction. A claim-bearing extension is
advertised only at protocol versions where at least one of its claims
is active.
"""
return {}
def claims(self) -> Sequence[ResultClaim[Any]]:
"""Extra result shapes this extension claims, with their resolvers."""
return ()
def notifications(self) -> Sequence[NotificationBinding[Any]]:
"""Server notifications this extension observes."""
return ()
class _AdvertiseOnly(ClientExtension):
"""Ad-only extension returned by `advertise()`."""
def __init__(self, identifier: str, settings: dict[str, Any]) -> None:
self.identifier = identifier
self._settings = settings
def settings(self) -> dict[str, Any]:
return self._settings
def advertise(identifier: str, settings: dict[str, Any] | None = None) -> ClientExtension:
"""Advertise an extension identifier (with optional settings) and nothing else.
Advertising an extension you do not implement asserts wire support you do
not have; for behavioral extensions construct the real extension instead.
"""
validate_extension_identifier(identifier, owner="advertise")
return _AdvertiseOnly(identifier, {} if settings is None else settings)
+263 -29
View File
@@ -1,15 +1,18 @@
from __future__ import annotations
import logging
from collections.abc import Callable, Mapping
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from functools import reduce
from operator import or_
from types import TracebackType
from typing import Any, Literal, Protocol, cast, overload
from typing import Annotated, Any, Final, Literal, Protocol, cast, overload
import anyio
import anyio.abc
import anyio.lowlevel
import mcp_types as types
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
@@ -27,10 +30,11 @@ from mcp_types.version import (
LATEST_MODERN_VERSION,
MODERN_PROTOCOL_VERSIONS,
)
from pydantic import BaseModel, TypeAdapter, ValidationError
from pydantic import BaseModel, Discriminator, Tag, TypeAdapter, ValidationError
from typing_extensions import Self, TypeVar, deprecated
from mcp.client._transport import ReadStream, WriteStream
from mcp.client.extension import NotificationBinding, ResultClaim, UnexpectedClaimedResult
from mcp.shared._compat import resync_tracer
from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, ProgressFnT
from mcp.shared.exceptions import MCPDeprecationWarning, MCPError
@@ -51,6 +55,7 @@ from mcp.shared.transport_context import TransportContext
DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0")
DISCOVER_TIMEOUT_SECONDS = 10.0
_NOTIFICATION_QUEUE_SIZE: Final = 256
logger = logging.getLogger("client")
@@ -189,7 +194,8 @@ async def _default_logging_callback(
ClientResponse: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter(types.ClientResult | types.ErrorData)
_CallToolResultAdapter: TypeAdapter[types.CallToolResult | types.InputRequiredResult] = TypeAdapter(
# Typed against the wide parse union so adopt-built claim adapters share this attribute type.
_CallToolResultAdapter: TypeAdapter[types.CallToolResult | types.InputRequiredResult | types.Result] = TypeAdapter(
types.CallToolResult | types.InputRequiredResult
)
_GetPromptResultAdapter: TypeAdapter[types.GetPromptResult | types.InputRequiredResult] = TypeAdapter(
@@ -200,6 +206,89 @@ _ReadResourceResultAdapter: TypeAdapter[types.ReadResourceResult | types.InputRe
)
def _claim_active(claim: ResultClaim[Any], version: str) -> bool:
"""A claim is active at modern versions only, narrowed by its optional version subset."""
return version in MODERN_PROTOCOL_VERSIONS and (
claim.protocol_versions is None or version in claim.protocol_versions
)
def _active_claims_at(
claims_by_extension: Mapping[str, tuple[ResultClaim[Any], ...]], version: str
) -> dict[str, ResultClaim[Any]]:
"""Claims active at `version`, keyed by wire tag; empty at any legacy version."""
return {
claim.result_type: claim
for claims in claims_by_extension.values()
for claim in claims
if _claim_active(claim, version)
}
def _build_call_tool_adapter(
active: Mapping[str, ResultClaim[Any]],
) -> TypeAdapter[types.CallToolResult | types.InputRequiredResult | types.Result]:
"""Build a discriminated tools/call adapter: a core arm plus one arm per active claim."""
if not active:
return _CallToolResultAdapter
tags = frozenset(active)
core_arm = "core"
while core_arm in tags: # the routing sentinel must never collide with a claimed tag
core_arm += "-"
def _route(value: Any) -> str:
# pydantic hands the discriminator either the raw dict or an already-built model.
# Unknown or non-string tags route to the core arm and fail core validation there.
if isinstance(value, dict):
tag = cast("dict[str, Any]", value).get("resultType")
else:
tag = getattr(value, "result_type", None)
return tag if isinstance(tag, str) and tag in tags else core_arm
arms: list[Any] = [Annotated[types.CallToolResult | types.InputRequiredResult, Tag(core_arm)]]
arms += [Annotated[claim.model, Tag(tag)] for tag, claim in active.items()]
# reduce(or_) rather than Union star-unpack, which needs py3.11+.
return TypeAdapter(Annotated[reduce(or_, arms), Discriminator(_route)])
def _index_claims(
result_claims: Mapping[str, Sequence[ResultClaim[Any]]] | None,
extensions: dict[str, dict[str, Any]] | None,
) -> dict[str, tuple[ResultClaim[Any], ...]]:
"""Validate and copy the claims-by-extension mapping."""
indexed: dict[str, tuple[ResultClaim[Any], ...]] = {}
seen: set[str] = set()
for identifier, claims in (result_claims or {}).items():
if extensions is None or identifier not in extensions:
raise ValueError(
f"result_claims key {identifier!r} has no extensions entry; a claim is only "
"advertised through its extension's capability ad"
)
if not claims:
raise ValueError(
f"result_claims[{identifier!r}] is empty and would drop the extension from "
"the capability ad at every version. Omit the key instead"
)
for claim in claims:
if claim.result_type in seen:
raise ValueError(f"duplicate result claim for resultType {claim.result_type!r}")
seen.add(claim.result_type)
indexed[identifier] = tuple(claims)
return indexed
def _index_bindings(
notification_bindings: Sequence[NotificationBinding[Any]] | None,
) -> dict[str, NotificationBinding[Any]]:
"""Index bindings by wire method, rejecting duplicates."""
indexed: dict[str, NotificationBinding[Any]] = {}
for binding in notification_bindings or ():
if binding.method in indexed:
raise ValueError(f"duplicate notification binding for method {binding.method!r}")
indexed[binding.method] = binding
return indexed
def _input_required_unexpected(method: str) -> RuntimeError:
return RuntimeError(
"Server returned InputRequiredResult; pass allow_input_required=True to receive it "
@@ -216,6 +305,9 @@ class ClientSession:
correlation; this class owns the typed MCP layer and the constructor
callbacks. Transport `Exception` items reach `message_handler` only when
the session builds its own dispatcher from a stream pair.
Extension `result_claims` fold into tools/call parsing at `adopt()`;
`notification_bindings` observe vendor notifications via bounded FIFOs.
"""
def __init__(
@@ -232,13 +324,22 @@ class ClientSession:
*,
sampling_capabilities: types.SamplingCapability | None = None,
extensions: dict[str, dict[str, Any]] | None = None,
result_claims: Mapping[str, Sequence[ResultClaim[Any]]] | None = None,
notification_bindings: Sequence[NotificationBinding[Any]] | None = None,
dispatcher: Dispatcher[Any] | None = None,
) -> None:
self._session_read_timeout_seconds = read_timeout_seconds
self._client_info = client_info or DEFAULT_CLIENT_INFO
self._sampling_callback = sampling_callback or _default_sampling_callback
self._sampling_capabilities = sampling_capabilities
self._extensions = extensions
self._extensions = dict(extensions) if extensions is not None else None
self._result_claims = _index_claims(result_claims, extensions)
self._notification_bindings = _index_bindings(notification_bindings)
self._active_claims: dict[str, ResultClaim[Any]] = {}
self._call_tool_adapter = _CallToolResultAdapter
self._binding_queues: dict[
str, tuple[MemoryObjectSendStream[BaseModel], MemoryObjectReceiveStream[BaseModel]]
] = {}
self._elicitation_callback = elicitation_callback or _default_elicitation_callback
self._list_roots_callback = list_roots_callback or _default_list_roots_callback
self._logging_callback = logging_callback or _default_logging_callback
@@ -274,7 +375,14 @@ class ClientSession:
self._task_group = anyio.create_task_group()
await self._task_group.__aenter__()
try:
# Queues must exist before the dispatcher starts: _on_notify enqueues into this dict.
for binding in self._notification_bindings.values():
send, receive = anyio.create_memory_object_stream[BaseModel](_NOTIFICATION_QUEUE_SIZE)
self._binding_queues[binding.method] = (send, receive)
await self._task_group.start(self._dispatcher.run, self._on_request, self._on_notify)
for binding in self._notification_bindings.values():
_, receive = self._binding_queues[binding.method]
self._task_group.start_soon(self._deliver_bound_notifications, binding, receive)
except BaseException:
# Unwind the entered task group before propagating: a cancellation
# landing here (e.g. `move_on_after` around connect) would abandon
@@ -285,7 +393,10 @@ class ClientSession:
# Shield the group's own scope (a new one would break LIFO exit)
# so a pending outer cancellation cannot re-fire inside __aexit__.
task_group.cancel_scope.shield = True
await task_group.__aexit__(None, None, None)
try:
await task_group.__aexit__(None, None, None)
finally:
self._close_binding_queues()
raise
return self
@@ -295,16 +406,38 @@ class ClientSession:
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> bool | None:
# Exit must not block: cancel the dispatcher and in-flight callbacks.
# Exit must not block: cancel the dispatcher, binding consumers, and in-flight callbacks.
assert self._task_group is not None
self._task_group.cancel_scope.cancel()
result = await self._task_group.__aexit__(exc_type, exc_val, exc_tb)
try:
result = await self._task_group.__aexit__(exc_type, exc_val, exc_tb)
finally:
self._close_binding_queues()
await resync_tracer()
return result
def _close_binding_queues(self) -> None:
# Unclosed memory object streams warn at garbage collection; close is idempotent.
for send, receive in self._binding_queues.values():
send.close()
receive.close()
self._binding_queues.clear()
async def _deliver_bound_notifications(
self, binding: NotificationBinding[Any], receive: MemoryObjectReceiveStream[BaseModel]
) -> None:
"""Consume one binding's FIFO, decoupled from the dispatcher so handlers can do session I/O."""
while True:
params = await receive.receive()
try:
await binding.handler(params)
except Exception:
# A raising handler costs only that delivery, as in _on_notify.
logger.exception("notification binding handler for %r raised", binding.method)
async def send_request(
self,
request: types.ClientRequest,
request: types.ClientRequest | types.Request[Any, Any],
result_type: type[ReceiveResultT] | TypeAdapter[ReceiveResultT],
request_read_timeout_seconds: float | None = None,
metadata: ClientMessageMetadata | None = None,
@@ -318,11 +451,20 @@ class ClientSession:
Raises:
MCPError: Error response, read timeout, or connection closed.
RuntimeError: Called before entering the context manager.
ValueError: The request declares `name_param` but its params carry no string name.
"""
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
method: str = data["method"]
opts: CallOptions = {}
self._stamp(data, opts)
# The stamp runs first, so its NAME_BEARING_METHODS rows win; a missing name fails loud.
headers = opts.setdefault("headers", {})
if (key := type(request).name_param) is not None and MCP_NAME_HEADER not in headers:
params_data: dict[str, Any] = data.get("params") or {}
name = params_data.get(key)
if not isinstance(name, str):
raise ValueError(f"{method} requires params[{key!r}] for Mcp-Name")
headers[MCP_NAME_HEADER] = encode_header_value(name)
timeout = (
request_read_timeout_seconds
if request_read_timeout_seconds is not None
@@ -360,7 +502,21 @@ class ClientSession:
self._stamp(data, opts)
await self._dispatcher.notify(data["method"], data.get("params"), opts)
def _build_capabilities(self) -> types.ClientCapabilities:
def _build_capabilities(self, version: str) -> types.ClientCapabilities:
"""Build the capability ad for a wire speaking `version`.
Claim-bearing identifiers whose claims are all inactive at `version` drop, so
the client never advertises result shapes it would reject; claim-less
identifiers always advertise.
"""
extensions = self._extensions
if extensions is not None and self._result_claims:
extensions = {
identifier: settings
for identifier, settings in extensions.items()
if identifier not in self._result_claims
or any(_claim_active(claim, version) for claim in self._result_claims[identifier])
} or None
sampling = (
(self._sampling_capabilities or types.SamplingCapability())
if self._sampling_callback is not _default_sampling_callback
@@ -380,7 +536,7 @@ class ClientSession:
else None
)
return types.ClientCapabilities(
sampling=sampling, elicitation=elicitation, experimental=None, extensions=self._extensions, roots=roots
sampling=sampling, elicitation=elicitation, experimental=None, extensions=extensions, roots=roots
)
async def initialize(self) -> types.InitializeResult:
@@ -390,7 +546,8 @@ class ClientSession:
types.InitializeRequest(
params=types.InitializeRequestParams(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=self._build_capabilities(),
# The handshake negotiates only legacy versions, where no claim is active.
capabilities=self._build_capabilities(LATEST_HANDSHAKE_VERSION),
client_info=self._client_info,
),
),
@@ -424,17 +581,30 @@ class ClientSession:
f"No mutually supported modern protocol version "
f"(server: {result.supported_versions}, client: {list(MODERN_PROTOCOL_VERSIONS)})"
)
version = mutual[-1]
client_info = self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True)
capabilities = self._build_capabilities().model_dump(by_alias=True, mode="json", exclude_none=True)
self._stamp = _make_modern_stamp(mutual[-1], client_info, capabilities, self._resolve_param_headers)
capabilities = self._build_capabilities(version).model_dump(by_alias=True, mode="json", exclude_none=True)
self._stamp = _make_modern_stamp(version, client_info, capabilities, self._resolve_param_headers)
self._discover_result = result
self._initialize_result = None
self._negotiated_version = mutual[-1]
else:
self._stamp = _make_handshake_stamp(result.protocol_version)
version = result.protocol_version
self._stamp = _make_handshake_stamp(version)
self._initialize_result = result
self._discover_result = None
self._negotiated_version = result.protocol_version
self._negotiated_version = version
# Both arms reach here, so re-adoption resets cleanly; legacy versions activate no claims.
# Core-vocabulary tags are unconstructible (ResultClaim.__post_init__), so no exclusion needed.
self._active_claims = _active_claims_at(self._result_claims, version)
self._call_tool_adapter = _build_call_tool_adapter(self._active_claims)
for method in self._notification_bindings:
# Bindings are consulted only for methods core does not know, so this one can never fire.
if (method, version) in _methods.SERVER_NOTIFICATIONS:
logger.warning(
"notification binding for %r will never fire at %s: the core protocol defines this method",
method,
version,
)
async def send_discover(self, version: str) -> dict[str, Any]:
"""Send a single ``server/discover`` at ``version`` and return the raw result dict.
@@ -450,7 +620,7 @@ class ClientSession:
synthesized into a JSON-RPC error by the transport).
"""
client_info = self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True)
capabilities = self._build_capabilities().model_dump(by_alias=True, mode="json", exclude_none=True)
capabilities = self._build_capabilities(version).model_dump(by_alias=True, mode="json", exclude_none=True)
request = types.DiscoverRequest(
params=types.RequestParams(
_meta={
@@ -704,6 +874,7 @@ class ClientSession:
request_state: str | None = None,
meta: RequestParamsMeta | None = None,
allow_input_required: Literal[False] = False,
allow_claimed: Literal[False] = False,
) -> types.CallToolResult: ...
@overload
@@ -718,8 +889,39 @@ class ClientSession:
request_state: str | None = None,
meta: RequestParamsMeta | None = None,
allow_input_required: bool,
allow_claimed: Literal[False] = False,
) -> types.CallToolResult | types.InputRequiredResult: ...
@overload
async def call_tool(
self,
name: str,
arguments: dict[str, Any] | None = None,
read_timeout_seconds: float | None = None,
progress_callback: ProgressFnT | None = None,
*,
input_responses: types.InputResponses | None = None,
request_state: str | None = None,
meta: RequestParamsMeta | None = None,
allow_input_required: Literal[False] = False,
allow_claimed: bool,
) -> types.CallToolResult | types.Result: ...
@overload
async def call_tool(
self,
name: str,
arguments: dict[str, Any] | None = None,
read_timeout_seconds: float | None = None,
progress_callback: ProgressFnT | None = None,
*,
input_responses: types.InputResponses | None = None,
request_state: str | None = None,
meta: RequestParamsMeta | None = None,
allow_input_required: bool,
allow_claimed: bool,
) -> types.CallToolResult | types.InputRequiredResult | types.Result: ...
async def call_tool(
self,
name: str,
@@ -731,7 +933,8 @@ class ClientSession:
request_state: str | None = None,
meta: RequestParamsMeta | None = None,
allow_input_required: bool = False,
) -> types.CallToolResult | types.InputRequiredResult:
allow_claimed: bool = False,
) -> types.CallToolResult | types.InputRequiredResult | types.Result:
"""Send a tools/call request with optional progress callback support.
On a modern (2026-07-28) connection, arguments annotated with `x-mcp-header`
@@ -745,10 +948,13 @@ class ClientSession:
allow_input_required: When ``False`` (default), an `InputRequiredResult`
from the server raises `RuntimeError`; when ``True``, it is returned
so the caller can resolve the requests and retry.
allow_claimed: When `False` (default), a claimed extension result raises
`UnexpectedClaimedResult`; when `True`, the parsed claim model is returned.
Raises:
RuntimeError: If the server returns an `InputRequiredResult` and
``allow_input_required`` is ``False``.
UnexpectedClaimedResult: Claimed result with `allow_claimed` False; carries the parsed value.
"""
result = await self.send_request(
types.CallToolRequest(
@@ -760,16 +966,19 @@ class ClientSession:
_meta=meta,
),
),
_CallToolResultAdapter,
self._call_tool_adapter,
request_read_timeout_seconds=read_timeout_seconds,
progress_callback=progress_callback,
)
if isinstance(result, types.CallToolResult) and not result.is_error:
await self._validate_tool_result(name, result)
await self.validate_tool_result(name, result)
# The input_required arm stays first; a claimed shape is terminal for the multi-round-trip driver.
if isinstance(result, types.InputRequiredResult) and not allow_input_required:
raise _input_required_unexpected("call_tool")
if not isinstance(result, types.CallToolResult | types.InputRequiredResult) and not allow_claimed:
raise UnexpectedClaimedResult(result)
return result
def _resolve_param_headers(self, name: str, arguments: Mapping[str, Any]) -> dict[str, str]:
@@ -779,8 +988,12 @@ class ClientSession:
return {}
return mcp_param_headers(header_map, arguments)
async def _validate_tool_result(self, name: str, result: types.CallToolResult) -> None:
"""Validate the structured content of a tool result against its output schema."""
async def validate_tool_result(self, name: str, result: types.CallToolResult) -> None:
"""Revalidate a `CallToolResult` against the tool's declared output schema.
Raises:
RuntimeError: Structured content is missing or does not conform to the schema.
"""
if name not in self._tool_output_schemas:
# refresh output schema cache
await self.list_tools()
@@ -970,7 +1183,7 @@ class ClientSession:
ctx = ClientRequestContext(
session=self, request_id=dctx.request_id, meta=request.params.meta if request.params else None
)
response = await self._dispatch_input_request(ctx, request)
response = await self.dispatch_input_request(ctx, request)
client_response = ClientResponse.validate_python(response)
if isinstance(client_response, types.ErrorData):
raise MCPError.from_error_data(client_response)
@@ -982,16 +1195,18 @@ class ClientSession:
raise MCPError(code=INTERNAL_ERROR, message="Client callback returned an invalid result") from None
return dumped
async def _dispatch_input_request(
self, ctx: ClientRequestContext, req: types.InputRequest
async def dispatch_input_request(
self, ctx: ClientRequestContext, request: types.InputRequest
) -> types.InputResponse | types.ErrorData:
"""Route a server-initiated input request to the matching constructor callback.
"""Route an input request through the client's callback table.
Shared by the legacy serverclient RPC path (`_on_request`) and the
2026-07-28 multi-round-trip driver, which dispatches the embedded
`InputRequiredResult.input_requests` through the same callbacks.
Returns the callback's `InputResponse`, or `ErrorData` when the callback declines.
"""
match req:
match request:
case types.CreateMessageRequest(params=p):
return await self._sampling_callback(ctx, p)
case types.ElicitRequest(params=p):
@@ -1008,7 +1223,26 @@ class ClientSession:
try:
notification = cast(types.ServerNotification, _methods.parse_server_notification(method, version, params))
except KeyError:
logger.debug("dropped %r: not defined at %s", method, version)
# Only methods unknown to the negotiated version's core tables reach the bindings.
binding = self._notification_bindings.get(method)
if binding is None:
logger.debug("dropped %r: not defined at %s", method, version)
return
try:
bound_params = binding.params_type.model_validate(params or {})
except ValidationError:
logger.warning("Failed to validate notification: %s", method, exc_info=True)
return
send, receive = self._binding_queues[method]
try:
# Must not await: DirectDispatcher calls _on_notify inline; blocking deadlocks in-process servers.
send.send_nowait(bound_params)
except anyio.WouldBlock:
# Evict the oldest event; no checkpoint since the failed send,
# so the buffer is still full and the retry cannot block.
receive.receive_nowait()
logger.warning("notification queue for %r is full; dropped the oldest event", method)
send.send_nowait(bound_params)
return
except ValidationError:
logger.warning("Failed to validate notification: %s", method, exc_info=True)
+3 -21
View File
@@ -19,7 +19,6 @@ extensions remain importable without constructing an `MCPServer`.
from __future__ import annotations
import re
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
@@ -30,31 +29,14 @@ from pydantic import BaseModel
from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext
# Re-exported from `mcp.shared.extension` (shared with the client surface) for existing importers.
from mcp.shared.extension import validate_extension_identifier as validate_extension_identifier
if TYPE_CHECKING:
from mcp.server.mcpserver.resources import Resource
RequestHandler = Callable[[ServerRequestContext[Any, Any], Any], Awaitable[HandlerResult]]
# Extension identifiers follow the `_meta` key grammar with a mandatory prefix
# (SEP-2133 / basic/index.mdx): dot-separated labels, each starting with a
# letter and ending with a letter or digit (hyphens interior), then `/`, then a
# name that starts and ends alphanumeric (`.`/`_`/`-` interior).
_LABEL = r"[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?"
_NAME = r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?"
_IDENTIFIER_RE = re.compile(rf"{_LABEL}(?:\.{_LABEL})*/{_NAME}")
def validate_extension_identifier(identifier: Any, *, owner: str) -> None:
"""Raise `TypeError` unless `identifier` is a `vendor-prefix/name` string.
SEP-2133 requires extension identifiers to carry a reverse-DNS prefix.
"""
if not isinstance(identifier, str) or not _IDENTIFIER_RE.fullmatch(identifier):
raise TypeError(
f"{owner}.identifier must be a `vendor-prefix/name` string "
f"(reverse-DNS prefix required), got {identifier!r}"
)
@dataclass(frozen=True)
class ToolBinding:
+28
View File
@@ -0,0 +1,28 @@
"""Extension-identifier grammar shared by the server and client extension surfaces."""
from __future__ import annotations
import re
from typing import Any
__all__ = ["validate_extension_identifier"]
# Extension identifiers follow the `_meta` key grammar with a mandatory prefix
# (SEP-2133 / basic/index.mdx): dot-separated labels, each starting with a
# letter and ending with a letter or digit (hyphens interior), then `/`, then a
# name that starts and ends alphanumeric (`.`/`_`/`-` interior).
_LABEL = r"[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?"
_NAME = r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?"
_IDENTIFIER_RE = re.compile(rf"{_LABEL}(?:\.{_LABEL})*/{_NAME}")
def validate_extension_identifier(identifier: Any, *, owner: str) -> None:
"""Raise `TypeError` unless `identifier` is a `vendor-prefix/name` string.
SEP-2133 requires extension identifiers to carry a reverse-DNS prefix.
"""
if not isinstance(identifier, str) or not _IDENTIFIER_RE.fullmatch(identifier):
raise TypeError(
f"{owner}.identifier must be a `vendor-prefix/name` string "
f"(reverse-DNS prefix required), got {identifier!r}"
)
+573
View File
@@ -0,0 +1,573 @@
"""`Client` + `ClientExtension` integration: extension declarations fold into the session at
construction, and `call_tool` drives claim resolvers transparently against real `MCPServer`s.
"""
import logging
from collections.abc import Awaitable, Callable, Sequence
from typing import Any, Literal, cast
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, Result, TextContent
from mcp_types.version import LATEST_MODERN_VERSION
from pydantic import BaseModel
from typing_extensions import assert_type
from mcp.client import ClaimContext, ClientExtension, NotificationBinding, ResultClaim, advertise
from mcp.client.client import Client
from mcp.client.session import ClientRequestContext, _CallToolResultAdapter
from mcp.server import Server, ServerRequestContext
from mcp.server.context import CallNext, HandlerResult
from mcp.server.extension import Extension
from mcp.server.mcpserver import Context, MCPServer
pytestmark = pytest.mark.anyio
_VOUCHER_EXT = "com.example/voucher"
_RIVAL_EXT = "com.example/rival"
_NAME_SCHEMA = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}
def _name_elicitation() -> types.ElicitRequest:
return types.ElicitRequest(
params=types.ElicitRequestFormParams(message="What is your name?", requested_schema=_NAME_SCHEMA)
)
class VoucherResult(Result):
"""The claimed `tools/call` shape, tagged `voucher`, carrying a vendor top-level field."""
result_type: Literal["voucher"] = "voucher"
voucher_code: str | None = None
_Resolver = Callable[[VoucherResult, ClaimContext], Awaitable[CallToolResult]]
class _VoucherExtension(ClientExtension):
"""Client half: claims the `voucher` tag with the supplied resolver."""
identifier = _VOUCHER_EXT
def __init__(self, resolve: _Resolver) -> None:
self._resolve = resolve
def claims(self) -> Sequence[ResultClaim[Any]]:
return [ResultClaim(result_type="voucher", model=VoucherResult, resolve=self._resolve)]
class _VoucherIssuer(Extension):
"""Server half: rewrites every `tools/call` result into the vendor-claimed shape."""
identifier = _VOUCHER_EXT
async def intercept_tool_call(
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
) -> HandlerResult:
return {"resultType": "voucher", "voucherCode": "v-42"}
class _TwoRoundVoucherIssuer(Extension):
"""Server half: demands input on the first round, then issues the claimed shape."""
identifier = _VOUCHER_EXT
async def intercept_tool_call(
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
) -> HandlerResult:
if params.input_responses is None:
return types.InputRequiredResult(input_requests={"user_name": _name_elicitation()})
return {"resultType": "voucher", "voucherCode": "after-input"}
def _voucher_server(issuer: Extension | None = None) -> MCPServer:
"""An `MCPServer` whose `issue` tool the server extension rewrites into the claimed shape."""
server = MCPServer("vouchers", extensions=[issuer if issuer is not None else _VoucherIssuer()])
@server.tool()
def issue() -> CallToolResult:
"""Issue a voucher."""
raise NotImplementedError # the server extension short-circuits before the tool runs
return server
def _structured_voucher_server() -> MCPServer:
"""Like `_voucher_server`, but `issue` declares an output schema (`-> str`)."""
server = MCPServer("vouchers", extensions=[_VoucherIssuer()])
@server.tool()
def issue() -> str:
"""Issue a voucher."""
raise NotImplementedError # the server extension short-circuits before the tool runs
return server
def _add_server() -> MCPServer:
"""A plain claim-less server with one ordinary tool."""
server = MCPServer("plain")
@server.tool()
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
return server
# Construction-time validation
class _CouponResult(Result):
result_type: Literal["coupon"] = "coupon"
async def _unreachable_coupon_resolve(claimed: _CouponResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError # the wrong resolver for a voucher; must never run
class _CouponExtension(ClientExtension):
identifier = "com.example/coupons"
def claims(self) -> Sequence[ResultClaim[Any]]:
return [ResultClaim(result_type="coupon", model=_CouponResult, resolve=_unreachable_coupon_resolve)]
class _SelfConflictingClaims(ClientExtension):
identifier = "com.example/twice"
def claims(self) -> Sequence[ResultClaim[Any]]:
return [
ResultClaim(result_type="twice", model=_TwiceResult, resolve=_unreachable_twice_resolve),
ResultClaim(result_type="twice", model=_TwiceResult, resolve=_unreachable_twice_resolve),
]
class _TwiceResult(Result):
result_type: Literal["twice"] = "twice"
async def _unreachable_twice_resolve(claimed: _TwiceResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError
def test_mapping_extensions_get_the_migration_error() -> None:
"""SDK-defined: the replaced dict form fails with a message naming the new shape."""
with pytest.raises(TypeError) as exc_info:
Client(_add_server(), extensions=cast("Sequence[ClientExtension]", {"com.example/ui": {}}))
assert str(exc_info.value) == snapshot(
"extensions= takes a sequence of ClientExtension instances. The mapping form was "
"replaced: use advertise(identifier, settings) for advertise-only entries"
)
def test_one_extension_claiming_a_tag_twice_reads_as_one_owner() -> None:
"""SDK-defined: a self-conflict names the one extension once, not as a pair."""
with pytest.raises(ValueError) as exc_info:
Client(_add_server(), extensions=[_SelfConflictingClaims()])
assert str(exc_info.value) == snapshot(
"extension 'com.example/twice' claims resultType 'twice'; a wire tag can have only one resolver"
)
def test_bare_extension_instance_is_rejected_with_the_fix_named() -> None:
"""SDK-defined: an instance whose class never set `identifier` fails construction naming the type and the fix."""
with pytest.raises(ValueError) as exc_info:
Client(_add_server(), extensions=[ClientExtension()])
assert str(exc_info.value) == snapshot(
"ClientExtension has no `identifier`; a ClientExtension must set the `identifier` "
"class attribute (or assign one in `__init__`) before it can be used"
)
class _SelfAssignedBadId(ClientExtension):
"""Assigns a malformed identifier in `__init__`, invisible at class definition."""
def __init__(self) -> None:
self.identifier = "not-prefixed"
def test_invalid_per_instance_identifier_raises_the_validators_error() -> None:
"""SDK-defined: per-instance identifiers are validated when the Client consumes the extension."""
with pytest.raises(TypeError) as exc_info:
Client(_add_server(), extensions=[_SelfAssignedBadId()])
assert str(exc_info.value) == snapshot(
"_SelfAssignedBadId.identifier must be a `vendor-prefix/name` string "
"(reverse-DNS prefix required), got 'not-prefixed'"
)
def test_duplicate_extension_identifiers_are_rejected_naming_the_identifier() -> None:
"""SDK-defined: one identifier cannot appear twice across the extensions sequence."""
with pytest.raises(ValueError) as exc_info:
Client(_add_server(), extensions=[advertise(_VOUCHER_EXT), advertise(_VOUCHER_EXT, {"a": 1})])
assert str(exc_info.value) == snapshot("extension identifier 'com.example/voucher' is passed more than once")
async def _unreachable_resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError
class _RivalVoucherExtension(ClientExtension):
identifier = _RIVAL_EXT
def claims(self) -> Sequence[ResultClaim[Any]]:
return [ResultClaim(result_type="voucher", model=VoucherResult, resolve=_unreachable_resolve)]
def test_conflicting_claims_across_extensions_name_both_owners() -> None:
"""SDK-defined: two extensions claiming the same tag fail at construction with both owners named."""
with pytest.raises(ValueError) as exc_info:
Client(_add_server(), extensions=[_VoucherExtension(_unreachable_resolve), _RivalVoucherExtension()])
assert str(exc_info.value) == snapshot(
"extensions 'com.example/voucher' and 'com.example/rival' both claim resultType "
"'voucher'; a wire tag can have only one resolver"
)
class _EventParams(BaseModel):
seq: int
async def _unreachable_handler(params: _EventParams) -> None:
raise NotImplementedError
class _ObserverA(ClientExtension):
identifier = "com.example/observer-a"
def notifications(self) -> Sequence[NotificationBinding[Any]]:
return [
NotificationBinding(
method="notifications/vendor/event", params_type=_EventParams, handler=_unreachable_handler
)
]
class _ObserverB(ClientExtension):
identifier = "com.example/observer-b"
def notifications(self) -> Sequence[NotificationBinding[Any]]:
return [
NotificationBinding(
method="notifications/vendor/event", params_type=_EventParams, handler=_unreachable_handler
)
]
def test_conflicting_notification_bindings_name_both_owners() -> None:
"""SDK-defined: two extensions binding the same notification method fail with both owners named."""
with pytest.raises(ValueError) as exc_info:
Client(_add_server(), extensions=[_ObserverA(), _ObserverB()])
assert str(exc_info.value) == snapshot(
"extensions 'com.example/observer-a' and 'com.example/observer-b' both bind "
"notification method 'notifications/vendor/event'; a method can have only one observer"
)
# settings() consumption
class _CountedResult(Result):
result_type: Literal["counted"] = "counted"
async def _unreachable_counted_resolve(claimed: _CountedResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError
class _CountingSettings(ClientExtension):
identifier = "com.example/counted"
def __init__(self) -> None:
self.reads = 0
self.claims_reads = 0
self.notifications_reads = 0
def settings(self) -> dict[str, Any]:
self.reads += 1
return {"read": self.reads}
def claims(self) -> Sequence[ResultClaim[Any]]:
self.claims_reads += 1
return [ResultClaim(result_type="counted", model=_CountedResult, resolve=_unreachable_counted_resolve)]
def notifications(self) -> Sequence[NotificationBinding[Any]]:
self.notifications_reads += 1
return [
NotificationBinding(method="notifications/counted", params_type=_EventParams, handler=_unreachable_handler)
]
async def test_declarations_are_read_exactly_once_at_construction() -> None:
"""SDK-defined: each declaration method is read exactly once, at Client construction, never again."""
extension = _CountingSettings()
client = Client(_add_server(), extensions=[extension])
assert (extension.reads, extension.claims_reads, extension.notifications_reads) == (1, 1, 1)
with anyio.fail_after(5):
async with client:
await client.call_tool("add", {"a": 1, "b": 2})
await client.call_tool("add", {"a": 3, "b": 4})
assert (extension.reads, extension.claims_reads, extension.notifications_reads) == (1, 1, 1)
async def test_settings_dict_is_held_by_reference_not_copied() -> None:
"""SDK-defined: the settings dict is held by reference, so mutating it before connect changes the ad."""
observed: list[dict[str, dict[str, Any]] | None] = []
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "probe"
assert ctx.session.client_params is not None
observed.append(ctx.session.client_params.capabilities.extensions)
return CallToolResult(content=[])
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})])
server = Server("probe", on_call_tool=call_tool, on_list_tools=list_tools)
settings = {"tier": "bronze"}
client = Client(server, extensions=[advertise("com.example/loyalty", settings)])
settings["tier"] = "gold"
with anyio.fail_after(5):
async with client:
await client.call_tool("probe", {})
assert observed == [{"com.example/loyalty": {"tier": "gold"}}]
# extensions=None stays byte-identical
@pytest.mark.parametrize("extensions", [None, ()], ids=["none", "empty"])
async def test_no_extensions_keeps_tools_call_parsing_byte_identical(
extensions: Sequence[ClientExtension] | None,
) -> None:
"""SDK-defined: `extensions=None` and an empty sequence leave the session exactly as a claim-less client's."""
with anyio.fail_after(5):
async with Client(_add_server(), extensions=extensions) as client:
assert client.session._call_tool_adapter is _CallToolResultAdapter
result = await client.call_tool("add", {"a": 1, "b": 2})
assert result.structured_content == {"result": 3}
# The transparent claim path
async def test_claimed_result_resolves_transparently_to_the_resolvers_result() -> None:
"""A claimed shape never surfaces: the resolver gets the parsed model and `call_tool` returns its product."""
received: list[VoucherResult] = []
produced: list[CallToolResult] = []
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
received.append(claimed)
product = CallToolResult(content=[TextContent(text=f"honored {claimed.voucher_code}")])
produced.append(product)
return product
with anyio.fail_after(5):
async with Client(_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client:
result = await client.call_tool("issue", {})
assert_type(result, CallToolResult)
assert [claimed.voucher_code for claimed in received] == ["v-42"]
assert result is produced[0]
assert result.content == [TextContent(text="honored v-42")]
async def test_claimed_shape_routes_to_its_owning_extensions_resolver() -> None:
"""With two claim-bearing extensions registered, the parsed shape runs its owner's resolver only."""
received: list[VoucherResult] = []
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
received.append(claimed)
return CallToolResult(content=[TextContent(text="routed")])
extensions = [_CouponExtension(), _VoucherExtension(resolve)]
with anyio.fail_after(5):
async with Client(_voucher_server(), extensions=extensions) as client:
result = await client.call_tool("issue", {})
assert [claimed.voucher_code for claimed in received] == ["v-42"]
assert result.content == [TextContent(text="routed")]
async def test_resolver_product_gets_the_direct_paths_output_schema_revalidation() -> None:
"""The resolver's product is revalidated against the tool's output schema exactly like a direct result."""
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
return CallToolResult(content=[TextContent(text="unstructured")])
async with Client(_structured_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client:
with anyio.fail_after(5), pytest.raises(RuntimeError) as exc_info:
await client.call_tool("issue", {})
assert str(exc_info.value) == snapshot("Tool issue has an output schema but did not return structured content")
async def test_resolver_error_result_is_returned_not_raised() -> None:
"""An `isError` resolver product skips output-schema revalidation and comes back as-is."""
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
return CallToolResult(content=[TextContent(text="voucher printer on fire")], is_error=True)
with anyio.fail_after(5):
async with Client(_structured_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client:
result = await client.call_tool("issue", {})
assert result.is_error
assert result.content == [TextContent(text="voucher printer on fire")]
async def test_resolver_receives_the_calls_claim_context() -> None:
"""`ClaimContext` carries the client's own session object, the tool name, and the per-call read timeout."""
contexts: list[ClaimContext] = []
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
contexts.append(ctx)
return CallToolResult(content=[])
with anyio.fail_after(5):
async with Client(_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client:
await client.call_tool("issue", {}, read_timeout_seconds=7.0)
[ctx] = contexts
assert ctx.session is client.session
assert ctx.tool_name == "issue"
assert ctx.read_timeout_seconds == 7.0
class _VoucherRefused(Exception):
"""Extension-owned error vocabulary."""
async def test_resolver_exception_propagates_untouched() -> None:
"""A resolver exception reaches the `call_tool` caller as the very object raised, unwrapped."""
refusal = _VoucherRefused("the voucher is refused")
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
raise refusal
async with Client(_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client:
with anyio.fail_after(5), pytest.raises(_VoucherRefused) as exc_info:
await client.call_tool("issue", {})
assert exc_info.value is refusal
# Unclaimed results with extensions present
async def test_unclaimed_result_flows_through_unchanged_with_extensions_present() -> None:
"""An ordinary `CallToolResult` is untouched by the claim machinery; the resolver never runs."""
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError # this server never produces a claimed shape
with anyio.fail_after(5):
async with Client(_add_server(), extensions=[_VoucherExtension(resolve)]) as client:
result = await client.call_tool("add", {"a": 1, "b": 2})
assert result.structured_content == {"result": 3}
async def test_input_required_then_plain_result_keeps_the_auto_loop_working() -> None:
"""With a claim-bearing extension present, the input_required auto loop on an unclaimed tool is unchanged."""
server = MCPServer("mrtr")
@server.tool()
async def greet(ctx: Context) -> str | types.InputRequiredResult:
responses = ctx.input_responses
if responses and "user_name" in responses:
answer = responses["user_name"]
assert isinstance(answer, types.ElicitResult)
assert answer.content is not None
return f"Hello, {answer.content['name']}!"
return types.InputRequiredResult(input_requests={"user_name": _name_elicitation()})
async def elicitation_callback(
context: ClientRequestContext, params: types.ElicitRequestParams
) -> types.ElicitResult | types.ErrorData:
return types.ElicitResult(action="accept", content={"name": "Ada"})
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError # this server never produces a claimed shape
with anyio.fail_after(5):
async with Client(
server, elicitation_callback=elicitation_callback, extensions=[_VoucherExtension(resolve)]
) as client:
result = await client.call_tool("greet")
assert result.content == [TextContent(text="Hello, Ada!")]
# The multi-round-trip + claimed interplay
async def test_input_required_then_claimed_result_on_retry_resolves_transparently() -> None:
"""A call that demands input first and returns a claimed shape on the retry still resolves transparently."""
prompted: list[str] = []
received: list[VoucherResult] = []
async def elicitation_callback(
context: ClientRequestContext, params: types.ElicitRequestParams
) -> types.ElicitResult | types.ErrorData:
assert isinstance(params, types.ElicitRequestFormParams)
prompted.append(params.message)
return types.ElicitResult(action="accept", content={"name": "Ada"})
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
received.append(claimed)
return CallToolResult(content=[TextContent(text=f"honored {claimed.voucher_code}")])
server = _voucher_server(issuer=_TwoRoundVoucherIssuer())
with anyio.fail_after(5):
async with Client(
server, elicitation_callback=elicitation_callback, extensions=[_VoucherExtension(resolve)]
) as client:
result = await client.call_tool("issue", {})
assert prompted == ["What is your name?"]
assert [claimed.voucher_code for claimed in received] == ["after-input"]
assert result.content == [TextContent(text="honored after-input")]
# Notification bindings fold into the session
class _CoreMethodObserver(ClientExtension):
"""Binds a method the modern core tables already define."""
identifier = "com.example/observer"
def notifications(self) -> Sequence[NotificationBinding[Any]]:
return [
NotificationBinding(method="notifications/message", params_type=_EventParams, handler=_unreachable_handler)
]
async def test_notification_bindings_fold_into_the_session(caplog: pytest.LogCaptureFixture) -> None:
"""The Client threads extension bindings into its session; a core-known binding draws the one-time warning."""
with caplog.at_level(logging.WARNING, logger="client"):
async with Client(_add_server(), extensions=[_CoreMethodObserver()]):
pass
expected = f"notification binding for 'notifications/message' will never fire at {LATEST_MODERN_VERSION}"
assert caplog.text.count(expected) == 1
+379
View File
@@ -0,0 +1,379 @@
"""Construction-time tests for `mcp.client.extension`; no session is ever opened."""
from dataclasses import FrozenInstanceError
from typing import Any, Literal, cast
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, InputRequiredResult, Result
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from pydantic import AliasChoices, AliasPath, BaseModel, Field
from pydantic.fields import FieldInfo
from mcp.client.extension import (
ClaimContext,
ClientExtension,
NotificationBinding,
ResultClaim,
_wire_keys,
advertise,
)
class _TaskResult(Result):
result_type: Literal["task"] = "task"
task_id: str = "t-1"
class _UntaggedResult(Result):
"""No `result_type` field at all."""
class _PlainStringTagResult(Result):
result_type: str = "task"
class _OtherTagResult(Result):
result_type: Literal["other"] = "other"
class _ClaimedCallToolResult(CallToolResult):
"""A core-result subclass; rejected as a claim model regardless of its tag."""
class _ClaimedInputRequiredResult(InputRequiredResult):
"""A core-result subclass; rejected as a claim model regardless of its tag."""
async def _resolve(result: Result, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError
def _claim(model: type[Result] = _TaskResult, **kwargs: Any) -> ResultClaim[Result]:
return ResultClaim(result_type="task", model=model, resolve=_resolve, **kwargs)
def test_claim_with_literal_discriminated_model_constructs() -> None:
"""SDK-defined: a model tagged with the claimed Literal constructs, defaulting to `tools/call` everywhere."""
claim = ResultClaim(result_type="task", model=_TaskResult, resolve=_resolve)
assert claim.result_type == "task"
assert claim.model is _TaskResult
assert claim.resolve is _resolve
assert claim.method == "tools/call"
assert claim.protocol_versions is None
def test_claim_accepts_modern_protocol_versions() -> None:
"""SDK-defined: a non-None `protocol_versions` subset of the modern revisions is accepted."""
versions = frozenset(MODERN_PROTOCOL_VERSIONS)
claim = _claim(protocol_versions=versions)
assert claim.protocol_versions == versions
def test_claim_rejects_core_result_type_vocabulary() -> None:
"""SDK-defined: a claim cannot re-key the core tags 'complete' and 'input_required'."""
messages: dict[str, str] = {}
for result_type in ("complete", "input_required"):
with pytest.raises(ValueError) as exc_info:
ResultClaim(result_type=result_type, model=_TaskResult, resolve=_resolve)
messages[result_type] = str(exc_info.value)
assert messages == snapshot(
{
"complete": "resultType 'complete' is core protocol vocabulary",
"input_required": "resultType 'input_required' is core protocol vocabulary",
}
)
@pytest.mark.parametrize("model", [_ClaimedCallToolResult, _ClaimedInputRequiredResult])
def test_claim_rejects_model_subclassing_core_result_types(model: type[Result]) -> None:
"""SDK-defined: a claim model subclassing a core result type is rejected; it would bypass claim routing."""
with pytest.raises(ValueError) as exc_info:
_claim(model=model)
assert str(exc_info.value) == snapshot("claim models must not subclass core result types")
def test_claim_rejects_model_without_result_type_field() -> None:
"""SDK-defined: the claim model must declare the discriminating `result_type` field."""
with pytest.raises(ValueError) as exc_info:
_claim(model=_UntaggedResult)
assert str(exc_info.value) == snapshot("_UntaggedResult.result_type must be Literal['task']")
def test_claim_rejects_plain_str_result_type_field() -> None:
"""SDK-defined: the model's `result_type` must be a Literal of the claimed tag, not a plain `str`."""
with pytest.raises(ValueError) as exc_info:
_claim(model=_PlainStringTagResult)
assert str(exc_info.value) == snapshot("_PlainStringTagResult.result_type must be Literal['task']")
def test_claim_rejects_mismatched_result_type_literal() -> None:
"""SDK-defined: the model's Literal tag must equal the claim's `result_type`."""
with pytest.raises(ValueError) as exc_info:
_claim(model=_OtherTagResult)
assert str(exc_info.value) == snapshot("_OtherTagResult.result_type must be Literal['task']")
class _NotAResult(BaseModel):
result_type: Literal["plain"] = "plain"
class _ReservedAliasResult(Result):
result_type: Literal["clash"] = "clash"
request_state: dict[str, Any] = {}
def test_claim_rejects_model_not_subclassing_result() -> None:
"""SDK-defined: a plain BaseModel cannot be a claim model; the session returns `Result` values."""
with pytest.raises(ValueError) as exc_info:
ResultClaim(result_type="plain", model=cast("type[Result]", _NotAResult), resolve=_resolve)
assert str(exc_info.value) == snapshot("_NotAResult must subclass mcp_types.Result")
def test_claim_rejects_model_aliasing_core_surface_fields() -> None:
"""SDK-defined: a field aliasing requestState or inputRequests would fail core pre-validation."""
with pytest.raises(ValueError) as exc_info:
ResultClaim(result_type="clash", model=_ReservedAliasResult, resolve=_resolve)
assert str(exc_info.value) == snapshot(
"_ReservedAliasResult.request_state aliases 'requestState', a typed field of the core "
"result surface; a colliding value would fail core validation before the claim adapter runs"
)
class _ValidationAliasResult(Result):
result_type: Literal["va"] = "va"
vendor_state: dict[str, Any] | None = Field(default=None, validation_alias="requestState")
class _SerializationAliasResult(Result):
result_type: Literal["sa"] = "sa"
vendor_state: dict[str, Any] | None = Field(default=None, serialization_alias="inputRequests")
class _AliasChoicesResult(Result):
result_type: Literal["ac"] = "ac"
vendor_state: dict[str, Any] | None = Field(
default=None, validation_alias=AliasChoices("vendorKey", "requestState")
)
class _AliasPathResult(Result):
result_type: Literal["ap"] = "ap"
vendor_state: dict[str, Any] | None = Field(
default=None, validation_alias=AliasChoices(AliasPath("requestState", "nested"))
)
def test_wire_keys_for_a_bare_field_is_just_its_name() -> None:
"""SDK-defined: a field with no aliases reads and writes only its own name."""
assert _wire_keys("plain", FieldInfo(annotation=str)) == frozenset({"plain"})
def test_claim_rejects_reserved_aliases_in_every_alias_form() -> None:
"""SDK-defined: validation_alias, serialization_alias, and AliasChoices routes to a reserved key are all caught."""
messages: dict[str, str] = {}
for model in (_ValidationAliasResult, _SerializationAliasResult, _AliasChoicesResult, _AliasPathResult):
with pytest.raises(ValueError) as exc_info:
ResultClaim(result_type=model.model_fields["result_type"].default, model=model, resolve=_resolve)
messages[model.__name__] = str(exc_info.value)
assert messages == snapshot(
{
"_ValidationAliasResult": "_ValidationAliasResult.vendor_state aliases "
"'requestState', a typed field of the core result surface; a colliding value would fail "
"core validation before the claim adapter runs",
"_SerializationAliasResult": "_SerializationAliasResult.vendor_state aliases "
"'inputRequests', a typed field of the core result surface; a colliding value would fail "
"core validation before the claim adapter runs",
"_AliasChoicesResult": "_AliasChoicesResult.vendor_state aliases 'requestState', a typed field of the core "
"result surface; a colliding value would fail core validation before the claim adapter runs",
"_AliasPathResult": "_AliasPathResult.vendor_state aliases "
"'requestState', a typed field of the core result surface; a colliding value would fail "
"core validation before the claim adapter runs",
}
)
def test_claim_rejects_method_outside_the_closed_verb_set() -> None:
"""SDK-defined: claims attach to `tools/call` only, even for values that dodge the static Literal gate."""
with pytest.raises(ValueError) as exc_info:
_claim(method=cast("Literal['tools/call']", "prompts/get"))
assert str(exc_info.value) == snapshot("claims attach to ['tools/call'] only; got method 'prompts/get'")
def test_claim_rejects_empty_protocol_versions() -> None:
"""SDK-defined: an empty version set is rejected; `None` is the spelling for every modern version."""
with pytest.raises(ValueError) as exc_info:
_claim(protocol_versions=frozenset())
assert str(exc_info.value) == snapshot("empty protocol_versions could never activate; use None for all")
def test_claim_rejects_non_modern_protocol_versions() -> None:
"""SDK-defined: a non-None version set must be a subset of the modern protocol revisions."""
messages: list[str] = []
for versions in (
frozenset({"2025-11-25"}),
frozenset({"2026-07-28", "2025-11-25"}),
frozenset({"never-a-version"}),
):
with pytest.raises(ValueError) as exc_info:
_claim(protocol_versions=versions)
messages.append(str(exc_info.value))
assert messages == snapshot(
[
"protocol_versions ['2025-11-25'] are not modern protocol revisions; claimed shapes "
"cannot be delivered on a legacy wire (None means every modern version)",
"protocol_versions ['2025-11-25'] are not modern protocol revisions; claimed shapes "
"cannot be delivered on a legacy wire (None means every modern version)",
"protocol_versions ['never-a-version'] are not modern protocol revisions; claimed shapes "
"cannot be delivered on a legacy wire (None means every modern version)",
]
)
def test_result_claim_is_frozen() -> None:
"""SDK-defined: claims are immutable; mutating one after construction raises."""
claim = _claim()
with pytest.raises(FrozenInstanceError):
setattr(claim, "result_type", "other") # direct assignment is also a type error
class _TaskNotificationParams(BaseModel):
task_id: str
async def _on_task(params: _TaskNotificationParams) -> None:
raise NotImplementedError
def test_notification_binding_constructs() -> None:
"""SDK-defined: a binding is a bare declaration with no construction-time validation."""
binding = NotificationBinding(method="notifications/tasks", params_type=_TaskNotificationParams, handler=_on_task)
assert binding.method == "notifications/tasks"
assert binding.params_type is _TaskNotificationParams
assert binding.handler is _on_task
def test_notification_binding_accepts_core_known_method() -> None:
"""SDK-defined: deliberately no spec-table check at construction, so packages survive core adopting a method."""
binding = NotificationBinding(
method="notifications/progress", params_type=_TaskNotificationParams, handler=_on_task
)
assert binding.method == "notifications/progress"
def test_notification_binding_is_frozen() -> None:
"""SDK-defined: bindings are immutable; mutating one after construction raises."""
binding = NotificationBinding(method="notifications/tasks", params_type=_TaskNotificationParams, handler=_on_task)
with pytest.raises(FrozenInstanceError):
setattr(binding, "method", "notifications/other") # direct assignment is also a type error
def test_extension_defaults_advertise_nothing() -> None:
"""SDK-defined: a minimal subclass advertises empty settings, no claims, and no bindings."""
class _MinimalExt(ClientExtension):
identifier = "com.example/minimal"
ext = _MinimalExt()
assert ext.settings() == {}
assert ext.claims() == ()
assert ext.notifications() == ()
@pytest.mark.parametrize(
"identifier",
[
"io.modelcontextprotocol/ui",
"com.example/my_ext",
"com.x-y.z2/n.a-b_c",
"example/x",
"a/b",
"com.example/9start",
],
)
def test_grammar_conformant_identifiers_accepted_at_class_definition(identifier: str) -> None:
"""Spec `_meta` key grammar: conformant `vendor-prefix/name` identifiers are accepted."""
cls = type("_GoodExt", (ClientExtension,), {"identifier": identifier})
assert cls.identifier == identifier
@pytest.mark.parametrize(
"identifier",
[
"noprefix",
"-foo/bar",
".leading/x",
"a..b/x",
"foo-/x",
"9foo/x",
"foo/-bar",
"foo/bar-",
"foo/",
"/bar",
"foo/ba r",
"io.modelcontextprotocol/ui\n",
"",
42,
],
)
def test_malformed_identifier_rejected_at_class_definition(identifier: Any) -> None:
"""SDK-defined: the SEP-2133 `vendor-prefix/name` grammar is enforced the moment the subclass is defined."""
with pytest.raises(TypeError):
type("_BadExt", (ClientExtension,), {"identifier": identifier})
def test_subclass_without_identifier_allowed_at_definition() -> None:
"""SDK-defined: a subclass with no class-level `identifier` is allowed; validation waits for consumption."""
class _AbstractishExt(ClientExtension):
"""Intermediate base; concrete subclasses supply the identifier."""
class _ConcreteExt(_AbstractishExt):
identifier = "com.example/concrete"
assert _ConcreteExt.identifier == "com.example/concrete"
def test_advertise_serves_captured_settings() -> None:
"""SDK-defined: `advertise()` returns an ad-only extension serving the captured settings."""
ext = advertise("com.example/flags", {"enabled": True})
assert isinstance(ext, ClientExtension)
assert ext.identifier == "com.example/flags"
assert ext.settings() == {"enabled": True}
assert ext.claims() == ()
assert ext.notifications() == ()
def test_advertise_defaults_to_empty_settings() -> None:
"""SDK-defined: omitting settings advertises the extension with an empty map."""
ext = advertise("com.example/flags")
assert ext.settings() == {}
@pytest.mark.parametrize("identifier", ["noprefix", "foo/", ""])
def test_advertise_validates_identifier_eagerly(identifier: str) -> None:
"""SDK-defined: `advertise()` validates the identifier eagerly, at the call site."""
with pytest.raises(TypeError):
advertise(identifier)
+251
View File
@@ -0,0 +1,251 @@
"""`ClientSession.send_request` mirrors `Request.name_param` into the `Mcp-Name`
header on send paths the core `NAME_BEARING_METHODS` table does not cover. The
vendor sends also pin the widened `send_request` typing (no cast needed)."""
from collections.abc import Mapping
from typing import Any, Literal
import anyio
import anyio.abc
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CallToolResult,
Implementation,
ListToolsResult,
Request,
ServerCapabilities,
TextContent,
Tool,
)
from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
from mcp.client.session import ClientSession
from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest
from mcp.shared.inbound import MCP_NAME_HEADER, MCP_PROTOCOL_VERSION_HEADER, encode_header_value
class _RecordingDispatcher:
"""Records `send_raw_request` opts and answers with canned per-method results."""
def __init__(self) -> None:
self.calls: list[tuple[str, CallOptions]] = []
async def run(
self,
on_request: OnRequest,
on_notify: OnNotify,
*,
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
) -> None:
task_status.started()
await anyio.sleep_forever()
async def send_raw_request(
self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None
) -> dict[str, Any]:
self.calls.append((method, opts or {}))
if method == "tools/call":
return CallToolResult(content=[TextContent(type="text", text="ok")]).model_dump(
by_alias=True, mode="json", exclude_none=True
)
if method == "tools/list":
return ListToolsResult(tools=[Tool(name="my-tool", input_schema={"type": "object"})]).model_dump(
by_alias=True, mode="json", exclude_none=True
)
return {}
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
raise NotImplementedError
class _GetWidgetParams(types.RequestParams):
widget_id: str
class _GetWidgetRequest(Request[_GetWidgetParams, Literal["vendor/widgets/get"]]):
method: Literal["vendor/widgets/get"] = "vendor/widgets/get"
name_param = "widgetId"
class _RawWidgetRequest(Request[dict[str, Any], Literal["vendor/widgets/get"]]):
"""Same wire shape with untyped params, so tests can omit or mistype the name value."""
method: Literal["vendor/widgets/get"] = "vendor/widgets/get"
name_param = "widgetId"
class _ShadowCallToolRequest(Request[dict[str, Any], Literal["tools/call"]]):
"""A vendor type declaring `name_param` for a method the core table already covers."""
method: Literal["tools/call"] = "tools/call"
name_param = "customKey"
class _PlainVendorRequest(Request[dict[str, Any], Literal["vendor/widgets/list"]]):
method: Literal["vendor/widgets/list"] = "vendor/widgets/list"
class _OptionalParamsWidgetRequest(Request[dict[str, Any] | None, Literal["vendor/widgets/get"]]):
"""Optional params, so a send can carry no params key at all."""
method: Literal["vendor/widgets/get"] = "vendor/widgets/get"
params: dict[str, Any] | None = None
name_param = "widgetId"
def _adopt_modern(session: ClientSession) -> None:
session.adopt(
types.DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="stub", version="0"),
)
)
def _adopt_handshake(session: ClientSession) -> None:
session.adopt(
types.InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="stub", version="0"),
)
)
def _headers(opts: CallOptions) -> dict[str, str]:
return opts.get("headers") or {}
@pytest.mark.anyio
async def test_vendor_name_param_emits_mcp_name_on_the_modern_path() -> None:
"""A vendor `name_param` emits `Mcp-Name` on a modern wire even outside `NAME_BEARING_METHODS`."""
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_modern(session)
await session.send_request(_GetWidgetRequest(params=_GetWidgetParams(widget_id="w-1")), types.EmptyResult)
[(_, opts)] = dispatcher.calls
assert _headers(opts)[MCP_NAME_HEADER] == "w-1"
@pytest.mark.anyio
async def test_vendor_name_param_emits_mcp_name_on_the_handshake_path() -> None:
"""The handshake stamp sets no `Mcp-Name`, so on a legacy wire the delta is the emitter."""
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_handshake(session)
await session.send_request(_GetWidgetRequest(params=_GetWidgetParams(widget_id="w-1")), types.EmptyResult)
[(_, opts)] = dispatcher.calls
assert _headers(opts)[MCP_NAME_HEADER] == "w-1"
# The stamp's own headers survive the delta.
assert _headers(opts)[MCP_PROTOCOL_VERSION_HEADER] == LATEST_HANDSHAKE_VERSION
@pytest.mark.anyio
async def test_name_value_passes_through_encode_header_value() -> None:
"""A non-ASCII name is base64-sentinel encoded, a spec MUST for `Mcp-Name`."""
name = "wídget ✨"
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_handshake(session)
await session.send_request(_GetWidgetRequest(params=_GetWidgetParams(widget_id=name)), types.EmptyResult)
[(_, opts)] = dispatcher.calls
assert _headers(opts)[MCP_NAME_HEADER] == encode_header_value(name)
assert _headers(opts)[MCP_NAME_HEADER].startswith("=?base64?")
@pytest.mark.anyio
async def test_core_tools_call_header_comes_from_the_stamp_alone() -> None:
"""Core `tools/call` is unchanged: the modern stamp emits the header; legacy stays headerless."""
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_modern(session)
await session.call_tool("my-tool", {})
_adopt_handshake(session)
await session.call_tool("my-tool", {})
(_, modern_opts), (_, legacy_opts) = (call for call in dispatcher.calls if call[0] == "tools/call")
assert _headers(modern_opts)[MCP_NAME_HEADER] == "my-tool"
assert MCP_NAME_HEADER not in _headers(legacy_opts)
@pytest.mark.anyio
async def test_stamp_table_rows_win_over_name_param_by_ordering() -> None:
"""A stamp-emitted `Mcp-Name` wins; `name_param` never overwrites an existing header."""
dispatcher = _RecordingDispatcher()
request = _ShadowCallToolRequest(params={"name": "real-tool", "customKey": "other-value"})
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_modern(session)
await session.send_request(request, types.CallToolResult)
[(_, opts)] = dispatcher.calls
assert _headers(opts)[MCP_NAME_HEADER] == "real-tool"
@pytest.mark.anyio
async def test_vendor_name_param_emits_mcp_name_on_the_preconnect_path() -> None:
"""Emission is era-unconditional: a session that never adopts still emits `Mcp-Name`."""
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
await session.send_request(_GetWidgetRequest(params=_GetWidgetParams(widget_id="w-1")), types.EmptyResult)
[(_, opts)] = dispatcher.calls
assert _headers(opts) == {MCP_NAME_HEADER: "w-1"} # and no era headers: nothing adopted
@pytest.mark.anyio
async def test_missing_name_value_fails_loud_naming_method_and_key() -> None:
"""A missing name value raises ValueError naming the method and key, before the wire."""
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_handshake(session)
with pytest.raises(ValueError) as exc_info:
await session.send_request(_RawWidgetRequest(params={}), types.EmptyResult)
assert dispatcher.calls == [] # raised before reaching the wire
assert str(exc_info.value) == snapshot("vendor/widgets/get requires params['widgetId'] for Mcp-Name")
@pytest.mark.anyio
async def test_non_string_name_value_fails_loud() -> None:
"""A non-string name value raises the same ValueError as a missing one."""
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_handshake(session)
with pytest.raises(ValueError) as exc_info:
await session.send_request(_RawWidgetRequest(params={"widgetId": 7}), types.EmptyResult)
assert dispatcher.calls == []
assert str(exc_info.value) == snapshot("vendor/widgets/get requires params['widgetId'] for Mcp-Name")
@pytest.mark.anyio
async def test_absent_params_fails_loud_not_attribute_error() -> None:
"""Absent params still raise the documented ValueError, not an AttributeError."""
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_handshake(session)
with pytest.raises(ValueError) as exc_info:
await session.send_request(_OptionalParamsWidgetRequest(), types.EmptyResult)
assert dispatcher.calls == []
assert str(exc_info.value) == snapshot("vendor/widgets/get requires params['widgetId'] for Mcp-Name")
@pytest.mark.anyio
async def test_request_without_name_param_sends_no_mcp_name() -> None:
"""No `name_param` and a method outside the core table emits no `Mcp-Name` on either era."""
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_modern(session)
await session.send_request(_PlainVendorRequest(params={}), types.EmptyResult)
_adopt_handshake(session)
await session.send_ping()
for _, opts in dispatcher.calls:
assert MCP_NAME_HEADER not in _headers(opts)
+468
View File
@@ -0,0 +1,468 @@
"""`ClientSession` result claims: construction validation, activation at modern
adopts only, claimed-result routing, the version-aware capability ad, and the
`allow_claimed` escape hatch."""
from collections.abc import Mapping
from typing import Any, Literal
import anyio
import anyio.abc
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
CallToolResult,
Implementation,
InputRequiredResult,
ListToolsResult,
Result,
ServerCapabilities,
TextContent,
Tool,
)
from mcp_types.methods import validate_server_result
from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
from pydantic import ValidationError
from typing_extensions import assert_type
from mcp.client.extension import ClaimContext, ResultClaim, UnexpectedClaimedResult
from mcp.client.session import ClientSession, _CallToolResultAdapter
from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest
_TASKS_EXT = "com.example/tasks"
_AD_ONLY_EXT = "com.example/flags"
class _TaskResult(Result):
"""A claimed result shape, tagged `task`."""
result_type: Literal["task"] = "task"
task_id: str
async def _resolve_task(result: _TaskResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError # session-tier tests never drive a resolver; that is the Client's job
def _task_claim(**kwargs: Any) -> ResultClaim[_TaskResult]:
return ResultClaim(result_type="task", model=_TaskResult, resolve=_resolve_task, **kwargs)
_COMPLETE_TOOL_RESULT = CallToolResult(content=[TextContent(type="text", text="ok")]).model_dump(
by_alias=True, mode="json", exclude_none=True
)
_CLAIMED_TASK_RESULT = {"resultType": "task", "taskId": "t-1"}
_TOOL_LISTING = ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})]).model_dump(
by_alias=True, mode="json", exclude_none=True
)
_INITIALIZE_RESULT = types.InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="stub", version="0"),
).model_dump(by_alias=True, mode="json", exclude_none=True)
class _RecordingDispatcher:
"""Records every send and answers each method with a canned result."""
def __init__(self, tool_result: dict[str, Any] | None = None) -> None:
self.calls: list[tuple[str, Mapping[str, Any] | None, CallOptions]] = []
self.notifications: list[str] = []
self._tool_result = tool_result if tool_result is not None else _COMPLETE_TOOL_RESULT
async def run(
self,
on_request: OnRequest,
on_notify: OnNotify,
*,
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
) -> None:
task_status.started()
await anyio.sleep_forever()
async def send_raw_request(
self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None
) -> dict[str, Any]:
self.calls.append((method, params, opts or {}))
if method == "tools/call":
return self._tool_result
if method == "tools/list":
return _TOOL_LISTING
if method == "initialize":
return _INITIALIZE_RESULT
return {}
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
self.notifications.append(method)
def _claims_session(dispatcher: _RecordingDispatcher, *claims: ResultClaim[Any]) -> ClientSession:
return ClientSession(dispatcher=dispatcher, extensions={_TASKS_EXT: {}}, result_claims={_TASKS_EXT: list(claims)})
def _adopt_modern(session: ClientSession) -> None:
session.adopt(
types.DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="stub", version="0"),
)
)
def _adopt_handshake(session: ClientSession) -> None:
session.adopt(
types.InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="stub", version="0"),
)
)
def test_duplicate_claim_tag_across_extensions_rejected() -> None:
"""SDK-defined: two claims on the same resultType cannot be routed apart, so construction fails."""
with pytest.raises(ValueError) as exc_info:
ClientSession(
dispatcher=_RecordingDispatcher(),
extensions={_TASKS_EXT: {}, _AD_ONLY_EXT: {}},
result_claims={_TASKS_EXT: [_task_claim()], _AD_ONLY_EXT: [_task_claim()]},
)
assert str(exc_info.value) == snapshot("duplicate result claim for resultType 'task'")
def test_claims_keyed_to_unadvertised_extension_rejected() -> None:
"""SDK-defined: a `result_claims` key with no `extensions` entry advertises nothing, so construction fails."""
messages: list[str] = []
for extensions in (None, {_AD_ONLY_EXT: {"flag": True}}):
with pytest.raises(ValueError) as exc_info:
ClientSession(
dispatcher=_RecordingDispatcher(),
extensions=extensions,
result_claims={_TASKS_EXT: [_task_claim()]},
)
messages.append(str(exc_info.value))
assert messages == snapshot(
[
"result_claims key 'com.example/tasks' has no extensions entry; a claim is only "
"advertised through its extension's capability ad",
"result_claims key 'com.example/tasks' has no extensions entry; a claim is only "
"advertised through its extension's capability ad",
]
)
def test_empty_claim_sequence_rejected() -> None:
"""SDK-defined: an empty claim list is rejected at construction; a claim-less extension omits the key."""
with pytest.raises(ValueError) as exc_info:
ClientSession(dispatcher=_RecordingDispatcher(), extensions={_TASKS_EXT: {}}, result_claims={_TASKS_EXT: []})
assert str(exc_info.value) == snapshot(
"result_claims['com.example/tasks'] is empty and would drop the extension from "
"the capability ad at every version. Omit the key instead"
)
def test_empty_settings_count_as_an_advertised_extension() -> None:
"""SDK-defined: empty settings ({}) still count as an ad, so claims keyed to the extension construct."""
session = _claims_session(_RecordingDispatcher(), _task_claim())
assert isinstance(session, ClientSession)
def test_without_claims_the_call_tool_adapter_is_the_module_constant() -> None:
"""SDK-defined: with zero active claims the session holds the module-level adapter by identity."""
session = ClientSession(dispatcher=_RecordingDispatcher())
assert session._call_tool_adapter is _CallToolResultAdapter
_adopt_modern(session)
assert session._call_tool_adapter is _CallToolResultAdapter
_adopt_handshake(session)
assert session._call_tool_adapter is _CallToolResultAdapter
@pytest.mark.anyio
@pytest.mark.parametrize("protocol_versions", [None, frozenset({LATEST_MODERN_VERSION})])
async def test_modern_adopt_activates_claims_and_routes_claimed_results(
protocol_versions: frozenset[str] | None,
) -> None:
"""SDK-defined: at a modern adopt, a claim active at the negotiated version routes
the claimed raw to the claim model."""
dispatcher = _RecordingDispatcher(tool_result=_CLAIMED_TASK_RESULT)
session = _claims_session(dispatcher, _task_claim(protocol_versions=protocol_versions))
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
result = await session.call_tool("t", {}, allow_claimed=True)
assert isinstance(result, _TaskResult)
assert result.task_id == "t-1"
@pytest.mark.anyio
async def test_legacy_adopt_clears_active_claims() -> None:
"""SDK-defined: a legacy adopt clears active claims and restores the module-level adapter."""
dispatcher = _RecordingDispatcher(tool_result=_CLAIMED_TASK_RESULT)
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
assert isinstance(await session.call_tool("t", {}, allow_claimed=True), _TaskResult)
_adopt_handshake(session)
assert session._call_tool_adapter is _CallToolResultAdapter
with pytest.raises(ValidationError):
await session.call_tool("t", {}, allow_claimed=True)
# Rejected at response parsing; the request did reach the wire.
assert dispatcher.calls[-1][0] == "tools/call"
@pytest.mark.anyio
async def test_modern_readopt_after_legacy_reactivates_claims() -> None:
"""SDK-defined: a modern re-adopt after legacy reactivates the claims."""
dispatcher = _RecordingDispatcher(tool_result=_CLAIMED_TASK_RESULT)
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
_adopt_handshake(session)
assert session._call_tool_adapter is _CallToolResultAdapter
_adopt_modern(session)
result = await session.call_tool("t", {}, allow_claimed=True)
assert isinstance(result, _TaskResult)
assert session._call_tool_adapter is not _CallToolResultAdapter
@pytest.mark.anyio
async def test_legacy_initialize_ad_drops_claim_bearing_identifiers() -> None:
"""SDK-defined: the legacy initialize ad drops claim-bearing identifiers; ad-only ones ride along."""
dispatcher = _RecordingDispatcher()
session = ClientSession(
dispatcher=dispatcher,
extensions={_TASKS_EXT: {}, _AD_ONLY_EXT: {"flag": True}},
result_claims={_TASKS_EXT: [_task_claim()]},
)
with anyio.fail_after(5):
async with session:
await session.initialize()
[(_, params, _)] = [call for call in dispatcher.calls if call[0] == "initialize"]
assert params is not None
assert params["capabilities"]["extensions"] == {_AD_ONLY_EXT: {"flag": True}}
@pytest.mark.anyio
async def test_legacy_ad_omits_extensions_entirely_when_every_identifier_drops() -> None:
"""SDK-defined: when every identifier drops, the ad omits the `extensions` key entirely."""
dispatcher = _RecordingDispatcher()
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
await session.initialize()
[(_, params, _)] = [call for call in dispatcher.calls if call[0] == "initialize"]
assert params is not None
assert "extensions" not in params["capabilities"]
@pytest.mark.anyio
async def test_modern_adopt_ad_includes_active_claim_identifiers() -> None:
"""SDK-defined: the modern per-request `_meta` ad includes identifiers whose claims are active."""
dispatcher = _RecordingDispatcher()
session = ClientSession(
dispatcher=dispatcher,
extensions={_TASKS_EXT: {}, _AD_ONLY_EXT: {"flag": True}},
result_claims={_TASKS_EXT: [_task_claim()]},
)
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
await session.send_ping()
[(_, params, _)] = dispatcher.calls
assert params is not None
capabilities = params["_meta"][CLIENT_CAPABILITIES_META_KEY]
assert capabilities["extensions"] == {_TASKS_EXT: {}, _AD_ONLY_EXT: {"flag": True}}
@pytest.mark.anyio
async def test_discover_probe_ad_includes_claim_identifiers_at_the_probe_version() -> None:
"""SDK-defined: `send_discover` builds its `_meta` ad at the probe version, where claims are active."""
dispatcher = _RecordingDispatcher()
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
await session.send_discover(LATEST_MODERN_VERSION)
[(_, params, _)] = dispatcher.calls
assert params is not None
capabilities = params["_meta"][CLIENT_CAPABILITIES_META_KEY]
assert capabilities["extensions"] == {_TASKS_EXT: {}}
@pytest.mark.anyio
async def test_discover_probe_ad_drops_claim_identifiers_at_a_legacy_probe_version() -> None:
"""SDK-defined: at a legacy probe version no claim can be active, so the identifier drops."""
dispatcher = _RecordingDispatcher()
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
await session.send_discover(LATEST_HANDSHAKE_VERSION)
[(_, params, _)] = dispatcher.calls
assert params is not None
capabilities = params["_meta"][CLIENT_CAPABILITIES_META_KEY]
assert "extensions" not in capabilities
class _CoreTaggedResult(Result):
"""A claim whose wire tag collides with the adapter's internal routing sentinel."""
result_type: Literal["core"] = "core"
payload: str = ""
async def _resolve_core_tagged(result: _CoreTaggedResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError
@pytest.mark.anyio
async def test_claim_tagged_core_cannot_hijack_core_parsing() -> None:
"""SDK-defined: a claim may use "core" as its wire tag without colliding with core parsing."""
claim = ResultClaim(result_type="core", model=_CoreTaggedResult, resolve=_resolve_core_tagged)
dispatcher = _RecordingDispatcher(tool_result={"resultType": "core", "payload": "p-1"})
session = ClientSession(dispatcher=dispatcher, extensions={_TASKS_EXT: {}}, result_claims={_TASKS_EXT: [claim]})
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
ordinary = session._call_tool_adapter.validate_python(_COMPLETE_TOOL_RESULT)
claimed = await session.call_tool("t", {}, allow_claimed=True)
assert isinstance(ordinary, CallToolResult)
assert isinstance(claimed, _CoreTaggedResult)
@pytest.mark.anyio
@pytest.mark.parametrize("with_claims", [True, False])
async def test_unknown_result_type_fails_validation_with_and_without_claims(with_claims: bool) -> None:
"""SDK-defined: a resultType outside the active claim set fails core validation, claims or not."""
raw = {"resultType": "weird", "taskId": "t-1"}
dispatcher = _RecordingDispatcher(tool_result=raw)
session = _claims_session(dispatcher, _task_claim()) if with_claims else ClientSession(dispatcher=dispatcher)
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
with pytest.raises(ValidationError):
await session.call_tool("t", {}, allow_claimed=True)
# Rejected at response parsing; the request did reach the wire.
assert dispatcher.calls[-1][0] == "tools/call"
@pytest.mark.anyio
async def test_non_string_result_type_fails_core_validation_not_discrimination() -> None:
"""SDK-defined: a non-string resultType stays on the core arm and fails as ValidationError, not TypeError."""
raw: dict[str, Any] = {"resultType": {"nested": True}}
dispatcher = _RecordingDispatcher(tool_result=raw)
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
with pytest.raises(ValidationError):
await session.call_tool("t", {}, allow_claimed=True)
# Rejected at response parsing; the request did reach the wire.
assert dispatcher.calls[-1][0] == "tools/call"
def test_adopt_built_adapter_revalidates_model_instances() -> None:
"""SDK-defined: the adopt-built adapter routes already-built model instances as well as raw dicts."""
session = _claims_session(_RecordingDispatcher(), _task_claim())
_adopt_modern(session)
adapter = session._call_tool_adapter
claimed = adapter.validate_python(_TaskResult(task_id="t-2"))
assert isinstance(claimed, _TaskResult)
core = adapter.validate_python(CallToolResult(content=[]))
assert isinstance(core, CallToolResult)
@pytest.mark.anyio
async def test_input_required_routes_to_core_arm_with_claims_active() -> None:
"""Spec-mandated: `input_required` is core vocabulary; active claims leave that arm untouched."""
raw = {"resultType": "input_required", "requestState": "s-1"}
session = _claims_session(_RecordingDispatcher(tool_result=raw), _task_claim())
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
result = await session.call_tool("t", {}, allow_input_required=True, allow_claimed=True)
assert isinstance(result, InputRequiredResult)
assert result.request_state == "s-1"
@pytest.mark.anyio
async def test_claimed_result_raises_unexpected_claimed_result_by_default() -> None:
"""SDK-defined: without `allow_claimed` a claimed shape raises, carrying the parsed
result so the caller can clean up any server-side state it references."""
dispatcher = _RecordingDispatcher(tool_result=_CLAIMED_TASK_RESULT)
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
with pytest.raises(UnexpectedClaimedResult) as exc_info:
await session.call_tool("t", {})
# The shape parsed and then raised; the request did reach the wire.
assert dispatcher.calls[-1][0] == "tools/call"
assert isinstance(exc_info.value.result, _TaskResult)
assert exc_info.value.result.task_id == "t-1"
assert str(exc_info.value) == snapshot(
"Server returned a claimed result (_TaskResult); pass the owning extension to "
"Client(extensions=[...]) for transparent resolution, or call with allow_claimed=True "
"and handle the shape. The carried result may reference server-side state needing cleanup."
)
@pytest.mark.anyio
async def test_call_tool_result_path_identical_under_both_allow_claimed_values() -> None:
"""SDK-defined: `allow_claimed` only affects claimed shapes; ordinary results come back identical."""
dispatcher = _RecordingDispatcher()
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
r_default = await session.call_tool("t", {})
r_opted = await session.call_tool("t", {}, allow_claimed=True)
assert isinstance(r_opted, CallToolResult)
assert r_opted == r_default
@pytest.mark.anyio
async def test_call_tool_overload_matrix_narrows_statically() -> None:
"""SDK-defined: each flag combination narrows `call_tool` to its documented return union under pyright."""
dispatcher = _RecordingDispatcher()
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
r1 = await session.call_tool("t", {})
assert_type(r1, CallToolResult)
r2 = await session.call_tool("t", {}, allow_input_required=True)
assert_type(r2, CallToolResult | InputRequiredResult)
r3 = await session.call_tool("t", {}, allow_claimed=True)
assert_type(r3, CallToolResult | Result)
r4 = await session.call_tool("t", {}, allow_input_required=True, allow_claimed=True)
assert_type(r4, CallToolResult | InputRequiredResult | Result)
assert [type(r) for r in (r1, r2, r3, r4)] == [CallToolResult] * 4
def test_claimed_raw_passes_v2026_tools_call_surface_validation() -> None:
"""Pins the claim path's dependency: an unknown resultType passes `validate_server_result`
at 2026-07-28; this failing is the signal that mcp-types tightened the surface."""
validate_server_result("tools/call", LATEST_MODERN_VERSION, {"resultType": "task", "taskId": "t-1"})
@@ -0,0 +1,287 @@
"""`ClientSession` notification bindings: serialized per-binding delivery through a
bounded FIFO, consulted only for methods the negotiated version's core tables do
not know."""
import logging
import anyio
import mcp_types as types
import pytest
from mcp_types import EmptyResult, Implementation, ServerCapabilities
from mcp_types.version import LATEST_MODERN_VERSION
from pydantic import BaseModel
from mcp.client.extension import NotificationBinding
from mcp.client.session import _NOTIFICATION_QUEUE_SIZE, ClientSession
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
from mcp.shared.dispatcher import DispatchContext
from mcp.shared.transport_context import TransportContext
_VENDOR_METHOD = "notifications/vendor/task_done"
class _EventParams(BaseModel):
seq: int
async def _server_on_request(
ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None
) -> dict[str, object]:
assert method == "ping"
return {}
async def _server_on_notify(
ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None
) -> None:
raise NotImplementedError
def _adopt_modern(session: ClientSession) -> None:
session.adopt(
types.DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="stub", version="0"),
)
)
async def _noop_handler(params: _EventParams) -> None:
raise NotImplementedError # construction-only tests never deliver
def test_duplicate_binding_method_rejected() -> None:
"""SDK-defined: two bindings on one wire method cannot be routed apart, so construction fails."""
client_side, _ = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=_noop_handler)
with pytest.raises(ValueError) as exc_info:
ClientSession(dispatcher=client_side, notification_bindings=[binding, binding])
assert str(exc_info.value) == "duplicate notification binding for method 'notifications/vendor/task_done'"
@pytest.mark.anyio
async def test_bound_vendor_notifications_are_delivered_in_order() -> None:
"""SDK-defined: one consumer per binding delivers events in the order the server sent them."""
delivered: list[int] = []
done = anyio.Event()
async def on_event(params: _EventParams) -> None:
delivered.append(params.seq)
if params.seq == 3:
done.set()
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
for seq in (1, 2, 3):
await server_side.notify(_VENDOR_METHOD, {"seq": seq})
await done.wait()
server_side.close()
assert delivered == [1, 2, 3]
@pytest.mark.anyio
async def test_binding_handler_may_do_session_io_without_deadlock() -> None:
"""SDK-defined: delivery is spawn-decoupled, so a handler may await session I/O without deadlock."""
pongs: list[EmptyResult] = []
done = anyio.Event()
client_side, server_side = create_direct_dispatcher_pair()
async def on_event(params: _EventParams) -> None:
pongs.append(await session.send_ping())
done.set()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
await server_side.notify(_VENDOR_METHOD, {"seq": 1})
await done.wait()
server_side.close()
assert pongs == [EmptyResult()]
@pytest.mark.anyio
async def test_overflow_drops_oldest_event_with_a_warning(caplog: pytest.LogCaptureFixture) -> None:
"""SDK-defined: on overflow the bounded FIFO drops the oldest queued event with a
warning; everything still queued delivers in order."""
delivered: list[int] = []
consumer_blocked = anyio.Event()
gate = anyio.Event()
done = anyio.Event()
last_seq = _NOTIFICATION_QUEUE_SIZE + 1
async def on_event(params: _EventParams) -> None:
delivered.append(params.seq)
if params.seq == 0:
consumer_blocked.set()
await gate.wait()
if params.seq == last_seq:
done.set()
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
await server_side.notify(_VENDOR_METHOD, {"seq": 0})
await consumer_blocked.wait()
for seq in range(1, last_seq + 1):
await server_side.notify(_VENDOR_METHOD, {"seq": seq})
gate.set()
await done.wait()
server_side.close()
assert delivered == [0, *range(2, last_seq + 1)]
assert caplog.text.count(f"notification queue for {_VENDOR_METHOD!r} is full") == 1
@pytest.mark.anyio
async def test_invalid_params_are_warned_and_dropped_without_reaching_handler(
caplog: pytest.LogCaptureFixture,
) -> None:
"""SDK-defined: params failing the binding's model are warned and dropped; later valid events deliver."""
delivered: list[int] = []
done = anyio.Event()
async def on_event(params: _EventParams) -> None:
delivered.append(params.seq)
done.set()
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
await server_side.notify(_VENDOR_METHOD, {"bogus": "no seq"})
await server_side.notify(_VENDOR_METHOD, {"seq": 1})
await done.wait()
server_side.close()
assert delivered == [1]
assert f"Failed to validate notification: {_VENDOR_METHOD}" in caplog.text
@pytest.mark.anyio
async def test_unbound_vendor_notification_keeps_the_debug_drop(caplog: pytest.LogCaptureFixture) -> None:
"""SDK-defined: a vendor method with no binding keeps the debug-log-and-drop behaviour."""
caplog.set_level(logging.DEBUG, logger="client")
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=_noop_handler)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
await server_side.notify("notifications/vendor/unbound", {"seq": 1})
server_side.close()
assert f"dropped 'notifications/vendor/unbound': not defined at {LATEST_MODERN_VERSION}" in caplog.text
@pytest.mark.anyio
async def test_core_known_method_never_reaches_binding_and_warns_once_at_adopt(
caplog: pytest.LogCaptureFixture,
) -> None:
"""SDK-defined: a binding for a core-known method never fires and warns once at
adopt(); the typed callback still runs."""
logged: list[types.LoggingMessageNotificationParams] = []
async def logging_callback(params: types.LoggingMessageNotificationParams) -> None:
logged.append(params)
async def on_message(params: BaseModel) -> None:
raise NotImplementedError # structurally unreachable: core parses the method first
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method="notifications/message", params_type=BaseModel, handler=on_message)
session = ClientSession(dispatcher=client_side, logging_callback=logging_callback, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
# In-process notify() awaits _on_notify inline, so the typed callback has already run.
await server_side.notify("notifications/message", {"level": "info", "data": "hello"})
server_side.close()
assert [params.data for params in logged] == ["hello"]
# The bound handler never ran; a delivery would have logged its NotImplementedError.
assert "notification binding handler" not in caplog.text
expected = f"notification binding for 'notifications/message' will never fire at {LATEST_MODERN_VERSION}"
assert caplog.text.count(expected) == 1
@pytest.mark.anyio
async def test_handler_exception_is_contained_and_later_events_deliver(caplog: pytest.LogCaptureFixture) -> None:
"""SDK-defined: a raising handler costs only that delivery; later events still deliver."""
delivered: list[int] = []
done = anyio.Event()
async def on_event(params: _EventParams) -> None:
if params.seq == 1:
raise ValueError("handler boom")
delivered.append(params.seq)
done.set()
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
await server_side.notify(_VENDOR_METHOD, {"seq": 1})
await server_side.notify(_VENDOR_METHOD, {"seq": 2})
await done.wait()
server_side.close()
assert delivered == [2]
assert f"notification binding handler for {_VENDOR_METHOD!r} raised" in caplog.text
@pytest.mark.anyio
async def test_binding_delivery_works_without_adopt() -> None:
"""SDK-defined: bindings deliver pre-handshake, under the default version tables."""
delivered: list[int] = []
done = anyio.Event()
async def on_event(params: _EventParams) -> None:
delivered.append(params.seq)
done.set()
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
await server_side.notify(_VENDOR_METHOD, {"seq": 7})
await done.wait()
server_side.close()
assert delivered == [7]
+66
View File
@@ -0,0 +1,66 @@
"""`dispatch_input_request` and `validate_tool_result` are public `ClientSession` API."""
import mcp_types as types
import pytest
from mcp_types import (
CallToolResult,
ErrorData,
ListRootsResult,
ListToolsResult,
PaginatedRequestParams,
Tool,
)
from mcp.client.client import Client
from mcp.client.session import ClientRequestContext, ClientSession
from mcp.server import Server, ServerRequestContext
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
@pytest.mark.anyio
async def test_dispatch_input_request_routes_through_the_callback_table() -> None:
expected = ListRootsResult(roots=[])
async def list_roots(context: ClientRequestContext) -> ListRootsResult:
return expected
client_side, _server_side = create_direct_dispatcher_pair()
session = ClientSession(dispatcher=client_side, list_roots_callback=list_roots)
ctx = ClientRequestContext(session=session, request_id="r-1")
response = await session.dispatch_input_request(ctx, types.ListRootsRequest())
assert response is expected
@pytest.mark.anyio
async def test_dispatch_input_request_returns_error_data_on_refusal() -> None:
"""With no callback registered, refusal comes back as `ErrorData`, not a raise."""
client_side, _server_side = create_direct_dispatcher_pair()
session = ClientSession(dispatcher=client_side)
ctx = ClientRequestContext(session=session, request_id="r-1")
response = await session.dispatch_input_request(ctx, types.ListRootsRequest())
assert isinstance(response, ErrorData)
assert response.code == types.INVALID_REQUEST
def _make_server(output_schema: dict[str, object]) -> Server:
async def on_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"}, output_schema=output_schema)])
return Server("test-server", on_list_tools=on_list_tools)
@pytest.mark.anyio
async def test_validate_tool_result_passes_a_conforming_result() -> None:
server = _make_server({"type": "object", "properties": {"x": {"type": "integer"}}, "required": ["x"]})
async with Client(server) as client:
# The session fetches the listing itself when the tool isn't cached yet.
await client.session.validate_tool_result("t", CallToolResult(content=[], structured_content={"x": 1}))
@pytest.mark.anyio
async def test_validate_tool_result_raises_on_schema_mismatch() -> None:
server = _make_server({"type": "object", "properties": {"x": {"type": "integer"}}, "required": ["x"]})
async with Client(server) as client:
# Stable SDK prefix only: the message tail is jsonschema text that shifts with the dependency.
with pytest.raises(RuntimeError, match="Invalid structured content returned by tool t"):
await client.session.validate_tool_result("t", CallToolResult(content=[], structured_content={"x": "no"}))
+4 -1
View File
@@ -7,6 +7,7 @@ from mcp_types import TextContent, TextResourceContents
from docs_src.apps import tutorial001, tutorial002, tutorial003
from mcp import Client
from mcp.client import advertise
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID
# See test_index.py for why this is a per-module mark and not a conftest hook.
@@ -34,7 +35,9 @@ async def test_the_ui_resource_is_served_as_the_app_mime_type() -> None:
async def test_one_tool_two_answers() -> None:
"""tutorial001: the canonical degradation pattern: raw data for a client that
negotiated Apps, a human sentence for one that did not."""
async with Client(tutorial001.mcp, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as ui_client:
async with Client(
tutorial001.mcp, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]
) as ui_client:
rich = await ui_client.call_tool("get_time", {})
async with Client(tutorial001.mcp) as text_client:
plain = await text_client.call_tool("get_time", {})
+41 -6
View File
@@ -1,15 +1,22 @@
"""`docs/advanced/extensions.md`: every claim the page makes, proved against the real SDK."""
import logging
from typing import cast
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import METHOD_NOT_FOUND, MISSING_REQUIRED_CLIENT_CAPABILITY, TextContent
from docs_src.extensions import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005
from docs_src.extensions import (
tutorial001,
tutorial002,
tutorial003,
tutorial004,
tutorial005,
tutorial006,
tutorial007,
)
from mcp import Client, MCPError
from mcp.client import advertise
from mcp.server.extension import Extension
# See test_index.py for why this is a per-module mark and not a conftest hook.
@@ -70,7 +77,7 @@ async def test_vendor_method_rejects_a_non_declaring_client_with_32021() -> None
async with Client(tutorial004.mcp) as client:
request = tutorial004.SearchRequest(params=tutorial004.SearchParams(query="mcp"))
with pytest.raises(MCPError) as exc_info:
await client.session.send_request(cast("types.ClientRequest", request), tutorial004.SearchResult)
await client.session.send_request(request, tutorial004.SearchResult)
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
assert exc_info.value.error.data == {"requiredCapabilities": {"extensions": {"com.example/search": {}}}}
@@ -78,10 +85,10 @@ async def test_vendor_method_rejects_a_non_declaring_client_with_32021() -> None
async def test_version_pinned_method_is_not_found_on_a_legacy_connection() -> None:
"""tutorial004: `protocol_versions={"2026-07-28"}` makes the method METHOD_NOT_FOUND
at any other wire version; for a legacy client it doesn't exist."""
async with Client(tutorial004.mcp, mode="legacy", extensions={tutorial004.EXTENSION_ID: {}}) as client:
async with Client(tutorial004.mcp, mode="legacy", extensions=[advertise(tutorial004.EXTENSION_ID)]) as client:
request = tutorial004.SearchRequest(params=tutorial004.SearchParams(query="mcp"))
with pytest.raises(MCPError) as exc_info:
await client.session.send_request(cast("types.ClientRequest", request), tutorial004.SearchResult)
await client.session.send_request(request, tutorial004.SearchResult)
assert exc_info.value.code == METHOD_NOT_FOUND
@@ -95,3 +102,31 @@ async def test_interceptor_observes_the_call_and_passes_the_result_through(
assert result.structured_content == {"result": 5}
messages = [record.getMessage() for record in caplog.records if record.name == tutorial005.logger.name]
assert messages == ["tool 'add' called"]
async def test_the_receipts_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None:
"""tutorial006: `main()` runs as printed and the output is the redeemed result, never the claimed shape."""
await tutorial006.main()
assert "goods for r-117" in capsys.readouterr().out
async def test_a_client_without_the_extension_is_refused_by_the_gate() -> None:
"""The page's off-by-default claim: the server's capability gate refuses a non-declaring client."""
async with Client(tutorial006.mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("buy", {"item": "lamp"})
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
async def test_session_tier_allow_claimed_returns_the_raw_shape() -> None:
"""The page's escape hatch: `allow_claimed=True` returns the parsed claim model, not the resolved result."""
async with Client(tutorial006.mcp, extensions=[tutorial006.Receipts()]) as client:
result = await client.session.call_tool("buy", {"item": "lamp"}, allow_claimed=True)
assert isinstance(result, tutorial006.ReceiptResult)
assert result.receipt_token == "r-117"
async def test_the_jobs_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None:
"""tutorial007: a vendor request with `name_param` round-trips `send_request` with no registration."""
await tutorial007.main()
assert "job-7 is running" in capsys.readouterr().out
+9 -1
View File
@@ -7,7 +7,7 @@ server's real Starlette app through the in-process streaming bridge, so the full
(session ids, SSE encoding, session management) runs with no sockets, threads, or subprocesses.
"""
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from functools import partial
from typing import Any, Protocol
@@ -30,6 +30,7 @@ from starlette.responses import Response
from starlette.routing import Mount, Route
from mcp.client.client import Client
from mcp.client.extension import ClientExtension
from mcp.client.session import ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamable_http_client
@@ -70,6 +71,7 @@ class Connect(Protocol):
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
extensions: Sequence[ClientExtension] | None = None,
spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> AbstractAsyncContextManager[Client]: ...
@@ -85,6 +87,7 @@ async def connect_in_memory(
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
extensions: Sequence[ClientExtension] | None = None,
spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> AsyncIterator[Client]:
"""Yield a Client connected to the server over the in-memory transport.
@@ -103,6 +106,7 @@ async def connect_in_memory(
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,
extensions=extensions,
) as client:
yield client
@@ -122,6 +126,7 @@ async def connect_over_streamable_http(
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
extensions: Sequence[ClientExtension] | None = None,
spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> AsyncIterator[Client]:
"""Yield a Client connected to the server's streamable HTTP app, entirely in process.
@@ -156,6 +161,7 @@ async def connect_over_streamable_http(
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,
extensions=extensions,
) as client,
):
yield client
@@ -357,6 +363,7 @@ async def connect_over_sse(
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
extensions: Sequence[ClientExtension] | None = None,
spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> AsyncIterator[Client]:
"""Yield a Client connected to the server's legacy SSE transport, entirely in process."""
@@ -390,5 +397,6 @@ async def connect_over_sse(
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,
extensions=extensions,
) as client:
yield client
+76
View File
@@ -2384,6 +2384,69 @@ REQUIREMENTS: dict[str, Requirement] = {
),
),
# ═══════════════════════════════════════════════════════════════════════════
# Extensions (SEP-2133): client-side result claims and the capability ad
# ═══════════════════════════════════════════════════════════════════════════
"extensions:client:claimed-result-resolved": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic#resulttype",
behavior=(
"A tools/call answered with an extension-claimed resultType is finished by the owning "
"ClientExtension's claim resolver, and Client.call_tool returns the resolver's ordinary "
"CallToolResult. The resolver may send follow-up requests through the session it is handed."
),
added_in="2026-07-28",
),
"extensions:client:claimed-result-undeclared-invalid": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic#resulttype",
behavior=(
"A resultType unrecognized by the client is invalid: a claimed shape delivered to a client that "
"did not construct the owning extension fails result validation (the supported set is core plus "
"declared claims, never more)."
),
added_in="2026-07-28",
note=(
"Known leniency: the monolith result surface still accepts an unknown tag when the payload "
"also parses as a complete core result (open result_type, extras ignored). Rejecting tags "
"outside core plus active claims is a tracked follow-up ruling."
),
),
"extensions:client:capability-ad:gates-server-behaviour": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic#resulttype",
behavior=(
"The per-request _meta capability ad carries each declared extension's identifier and settings, "
"and is what entitles the server to substitute that extension's claimed shapes: a server "
"extension gating on the ad sees the declared settings, and refuses a non-declaring client with "
"-32021 (missing required client capability)."
),
added_in="2026-07-28",
),
"extensions:client:capability-ad:legacy-omits-claimed": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic#resulttype",
behavior=(
"On a legacy connection no claim can activate, and the initialize capability ad omits "
"claim-bearing identifiers in the same breath (claim-less identifiers still advertise), so the "
"client never advertises an extension whose claimed shapes it would reject."
),
removed_in="2026-07-28",
note=(
"The legacy-era half of the ad/claims coupling: only a handshake connection can exhibit it, so "
"the version window ends where the modern era begins."
),
arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),),
),
"extensions:client:notification-binding-delivery": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic#resulttype",
behavior=(
"A vendor server notification bound by a ClientExtension's NotificationBinding is validated "
"against the binding's params type and delivered to its handler serially, in dispatch order."
),
added_in="2026-07-28",
deferred=(
"Covered at session tier by tests/client/test_session_notification_bindings.py: no public "
"server-side surface emits vendor-method notifications (ServerNotification is a closed union), "
"and HTTP-modern arrival additionally needs the subscriptions/listen client runtime."
),
),
# ═══════════════════════════════════════════════════════════════════════════
# Transports (in-suite coverage)
# ═══════════════════════════════════════════════════════════════════════════
"transport:streamable-http:stateful": Requirement(
@@ -3341,6 +3404,19 @@ REQUIREMENTS: dict[str, Requirement] = {
transports=("streamable-http",),
note="Only observable over streamable HTTP: headers are derived from the cached tool schema at the seam.",
),
"client-transport:http:vendor-name-param-header": Requirement(
source="sdk",
behavior=(
"A vendor request type declaring name_param mirrors that wire-params key into the Mcp-Name "
"header of its outgoing HTTP request, with no client-side registration of the method."
),
added_in="2026-07-28",
transports=("streamable-http",),
note=(
"SDK mechanism honouring the per-extension Mcp-Name requirements (e.g. SEP-2663 mandates the "
"header for tasks/*); only observable over streamable HTTP, where headers exist."
),
),
"client-transport:http:stateless-ignores-session-id": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/transports#stateless-request-headers",
behavior=(
@@ -0,0 +1,174 @@
"""Client extensions (SEP-2133) over the full client-server loop: a server extension
substitutes a claimed `tools/call` shape and the declaring client's `ClientExtension` resolves it."""
from collections.abc import Awaitable, Callable, Sequence
from typing import Any, Literal
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import MISSING_REQUIRED_CLIENT_CAPABILITY, CallToolResult, Result, TextContent
from pydantic import ValidationError
from mcp import MCPError
from mcp.client import ClaimContext, ClientExtension, ResultClaim, advertise
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
from mcp.server.extension import Extension
from mcp.server.mcpserver import Context, MCPServer, require_client_extension
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
_RECEIPTS = "com.example/receipts"
_FLAGS = "com.example/flags"
class ReceiptResult(Result):
result_type: Literal["receipt"] = "receipt"
receipt_token: str
settings_echo: dict[str, Any] | None = None
_Resolver = Callable[[ReceiptResult, ClaimContext], Awaitable[CallToolResult]]
class Receipts(ClientExtension):
"""Client half: claims the `receipt` shape with the test's resolver and settings."""
identifier = _RECEIPTS
def __init__(self, resolve: _Resolver, settings: dict[str, Any] | None = None) -> None:
self._resolve = resolve
self._settings = {} if settings is None else settings
def settings(self) -> dict[str, Any]:
return self._settings
def claims(self) -> Sequence[ResultClaim[Any]]:
return [ResultClaim(result_type="receipt", model=ReceiptResult, resolve=self._resolve)]
class _ReceiptIssuer(Extension):
"""Server half: answers `buy` with the claimed shape; every other tool passes through."""
identifier = _RECEIPTS
async def intercept_tool_call(
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
) -> HandlerResult:
if params.name != "buy":
return await call_next(ctx)
return {"resultType": "receipt", "receiptToken": "r-117"}
def _receipt_shop(issuer: Extension) -> MCPServer:
server = MCPServer("shop", extensions=[issuer])
@server.tool()
def buy(item: str) -> CallToolResult:
"""Buy an item."""
raise NotImplementedError # the server extension answers `buy` before the tool runs
@server.tool()
def redeem(token: str) -> str:
"""Exchange a receipt token for the goods."""
return f"goods for {token}"
return server
@requirement("extensions:client:claimed-result-resolved")
async def test_claimed_result_is_finished_by_the_owning_extensions_resolver(connect: Connect) -> None:
"""The owning extension's claim resolver redeems the substituted `receipt` through
`ctx.session`, and `call_tool` returns the resolver's plain `CallToolResult`."""
received: list[ReceiptResult] = []
async def redeem_receipt(claimed: ReceiptResult, ctx: ClaimContext) -> CallToolResult:
received.append(claimed)
return await ctx.session.call_tool("redeem", {"token": claimed.receipt_token})
async with connect(_receipt_shop(_ReceiptIssuer()), extensions=[Receipts(redeem_receipt)]) as client:
result = await client.call_tool("buy", {"item": "lamp"})
assert [claimed.receipt_token for claimed in received] == ["r-117"]
assert result == snapshot(
CallToolResult(content=[TextContent(text="goods for r-117")], structured_content={"result": "goods for r-117"})
)
@requirement("extensions:client:claimed-result-undeclared-invalid")
async def test_claimed_shape_fails_validation_for_a_client_without_the_extension(connect: Connect) -> None:
"""Spec-mandated: an unrecognized `resultType` is invalid, so a client without the
owning extension fails to parse the claimed shape."""
async with connect(_receipt_shop(_ReceiptIssuer())) as client:
with pytest.raises(ValidationError):
await client.call_tool("buy", {"item": "lamp"})
class _SettingsEchoIssuer(Extension):
"""Server half: requires the declaring client, then echoes its declared settings."""
identifier = _RECEIPTS
async def intercept_tool_call(
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
) -> HandlerResult:
require_client_extension(ctx, _RECEIPTS)
client_params = ctx.session.client_params
assert client_params is not None
extensions = client_params.capabilities.extensions
assert extensions is not None
return {"resultType": "receipt", "receiptToken": "echo", "settingsEcho": extensions[_RECEIPTS]}
@requirement("extensions:client:capability-ad:gates-server-behaviour")
async def test_per_request_ad_carries_settings_and_gates_the_claimed_substitution(connect: Connect) -> None:
"""The per-request `_meta` capability ad gates the claimed substitution: declared
settings reach the resolver and a non-declaring client is refused with -32021."""
server = MCPServer("shop", extensions=[_SettingsEchoIssuer()])
@server.tool()
def buy(item: str) -> CallToolResult:
"""Buy an item."""
raise NotImplementedError # the server extension answers `buy` before the tool runs
received: list[ReceiptResult] = []
async def keep(claimed: ReceiptResult, ctx: ClaimContext) -> CallToolResult:
received.append(claimed)
return CallToolResult(content=[TextContent(text="done")])
async with connect(server, extensions=[Receipts(keep, settings={"tier": "gold"})]) as client:
result = await client.call_tool("buy", {"item": "lamp"})
assert result.content == [TextContent(text="done")]
assert [claimed.settings_echo for claimed in received] == [{"tier": "gold"}]
async with connect(server) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("buy", {"item": "lamp"})
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
async def _unreachable_resolve(claimed: ReceiptResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError # no claimed shape can be delivered on a legacy wire
@requirement("extensions:client:capability-ad:legacy-omits-claimed")
async def test_legacy_ad_omits_claim_bearing_identifiers_but_keeps_claim_less_ones(connect: Connect) -> None:
"""On a legacy connection the claim-bearing identifier drops out of the initialize
capability ad while an ad-only identifier still advertises."""
server = MCPServer("introspector")
@server.tool()
def declared(ctx: Context) -> list[str]:
"""Report the extension identifiers the client advertised."""
capabilities = ctx.client_capabilities
assert capabilities is not None
return sorted(capabilities.extensions or {})
client_extensions = [Receipts(_unreachable_resolve), advertise(_FLAGS)]
async with connect(server, extensions=client_extensions) as client:
result = await client.call_tool("declared", {})
assert result.structured_content == {"result": [_FLAGS]}
@@ -9,7 +9,7 @@ result-envelope shape, so every assertion here is necessarily wire-level.
import json
from collections.abc import Callable
from typing import Any
from typing import Any, Literal
import anyio
import httpx
@@ -30,7 +30,9 @@ from mcp_types import (
JSONRPCResponse,
ListToolsResult,
PaginatedRequestParams,
Request,
RequestParams,
Result,
ServerCapabilities,
TextContent,
Tool,
@@ -551,3 +553,56 @@ async def test_modern_client_stops_mirroring_after_a_re_list_drops_the_tool() ->
before, after = tool_calls
assert before.headers.get("mcp-param-region") == "x"
assert not any(k.startswith("mcp-param-") for k in after.headers)
class _JobParams(RequestParams):
job_id: str
class _JobStatusRequest(Request[_JobParams, Literal["com.example/jobs.status"]]):
method: Literal["com.example/jobs.status"] = "com.example/jobs.status"
name_param = "jobId"
class _JobStatusResult(Result):
status: str
@requirement("client-transport:http:vendor-name-param-header")
async def test_vendor_request_with_name_param_carries_mcp_name_on_the_wire() -> None:
"""`send_request` mirrors an unregistered vendor request's `name_param` value into the
`Mcp-Name` header while the body keeps the params key unchanged."""
async def job_status(ctx: ServerRequestContext, params: _JobParams) -> _JobStatusResult:
assert params.job_id == "job-7"
return _JobStatusResult(status="running")
server = _server()
server.add_request_handler("com.example/jobs.status", _JobParams, job_status)
requests: list[httpx.Request] = []
async def on_request(request: httpx.Request) -> None:
requests.append(request)
discover = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="srv", version="0"),
)
with anyio.fail_after(5):
async with (
mounted_app(server, on_request=on_request) as (http, _),
Client(
streamable_http_client(f"{BASE_URL}/mcp", http_client=http),
mode=LATEST_MODERN_VERSION,
prior_discover=discover,
) as client,
):
request = _JobStatusRequest(params=_JobParams(job_id="job-7"))
result = await client.session.send_request(request, _JobStatusResult)
assert result.status == "running"
[wire_request] = requests
assert wire_request.headers["mcp-name"] == "job-7"
assert json.loads(wire_request.content)["params"]["jobId"] == "job-7"
+5 -49
View File
@@ -18,6 +18,7 @@ from mcp_types import (
TextContent,
)
from mcp.client import advertise
from mcp.client.client import Client
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
from mcp.server.extension import (
@@ -26,7 +27,6 @@ from mcp.server.extension import (
ResourceBinding,
ToolBinding,
compose_tool_call_interceptor,
validate_extension_identifier,
)
from mcp.server.mcpserver import Context, MCPServer, require_client_extension
from mcp.server.mcpserver.resources import TextResource
@@ -193,7 +193,7 @@ async def test_extension_method_reachable_via_session_send_request() -> None:
async with Client(server) as client:
request = _PingRequest(params=_PingParams())
result = await client.session.send_request(cast("types.ClientRequest", request), _PingResult)
result = await client.session.send_request(request, _PingResult)
assert result == snapshot(_PingResult(pong=True))
@@ -343,7 +343,7 @@ async def test_version_pinned_method_is_served_at_an_allowed_version() -> None:
async with Client(server, mode="2026-07-28") as client:
request = _VersionPinnedRequest(params=_VersionPinnedParams())
result = await client.session.send_request(cast("types.ClientRequest", request), _VersionPinnedResult)
result = await client.session.send_request(request, _VersionPinnedResult)
assert result == snapshot(_VersionPinnedResult(ok=True))
@@ -356,56 +356,12 @@ async def test_version_pinned_method_is_method_not_found_at_a_disallowed_version
async with Client(server, mode="legacy") as client:
request = _VersionPinnedRequest(params=_VersionPinnedParams())
with pytest.raises(MCPError) as exc_info:
await client.session.send_request(cast("types.ClientRequest", request), _VersionPinnedResult)
await client.session.send_request(request, _VersionPinnedResult)
assert exc_info.value.code == METHOD_NOT_FOUND
assert exc_info.value.error.data == "com.example/pinned"
@pytest.mark.parametrize(
"identifier",
[
"io.modelcontextprotocol/ui",
"com.example/my_ext",
"com.x-y.z2/n.a-b_c",
"example/x",
"a/b",
"com.example/9start",
],
)
def test_grammar_conformant_extension_identifiers_are_accepted(identifier: str) -> None:
"""Spec `_meta` key grammar: dot-separated labels (letter start, letter/digit end,
hyphens interior), a slash, then a name that starts and ends alphanumeric."""
validate_extension_identifier(identifier, owner="T")
@pytest.mark.parametrize(
"identifier",
[
"noprefix",
"-foo/bar",
".leading/x",
"a..b/x",
"foo-/x",
"9foo/x",
"foo/-bar",
"foo/bar-",
"foo/",
"/bar",
"foo/ba r",
"io.modelcontextprotocol/ui\n",
"",
None,
42,
],
)
def test_malformed_extension_identifiers_are_rejected(identifier: Any) -> None:
"""Spec `_meta` key grammar: malformed prefixes (bad label start/end, empty labels)
and malformed names are rejected, as are non-strings."""
with pytest.raises(TypeError):
validate_extension_identifier(identifier, owner="T")
@pytest.mark.parametrize("method", ["tools/list", "completion/complete"])
def test_method_binding_rejects_spec_methods(method: str) -> None:
"""SDK-defined: extension methods are additive — binding a spec-defined request method
@@ -466,7 +422,7 @@ async def test_require_client_extension_passes_when_client_declared_it() -> None
"""SDK-defined: `require_client_extension` is a no-op when the client advertised the id."""
server = MCPServer("test", extensions=[_RequiresExt()])
async with Client(server, extensions={_NEEDS_EXT: {}}) as client:
async with Client(server, extensions=[advertise(_NEEDS_EXT)]) as client:
result = await client.call_tool("guarded", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"}))
+11 -9
View File
@@ -14,6 +14,7 @@ import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, ReadResourceResult, TextContent, TextResourceContents
from mcp.client import advertise
from mcp.client.client import Client
from mcp.server import Server, ServerRequestContext
from mcp.server.apps import (
@@ -95,7 +96,7 @@ async def test_apps_tool_returns_rich_output_when_client_negotiated_apps() -> No
branching on `client_supports_apps(ctx)`, drives both halves."""
server = _clock_server()
async with Client(server, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as supports:
async with Client(server, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]) as supports:
rich = await supports.call_tool("get_time", {})
async with Client(server) as plain:
fallback = await plain.call_tool("get_time", {})
@@ -104,7 +105,7 @@ async def test_apps_tool_returns_rich_output_when_client_negotiated_apps() -> No
assert fallback.content == snapshot([TextContent(text="The time is 2026-06-26T00:00:00Z.")])
async def _observed_client_supports_apps(extensions: dict[str, dict[str, Any]] | None) -> bool:
async def _observed_client_supports_apps(ui_settings: dict[str, Any] | None) -> bool:
"""Run one probe `tools/call` and report what `client_supports_apps` saw server-side.
Exercises the lowlevel `ServerRequestContext` form, which reads the client's
@@ -123,29 +124,30 @@ async def _observed_client_supports_apps(extensions: dict[str, dict[str, Any]] |
return CallToolResult(content=[TextContent(text="ok")])
server = Server("probe", on_list_tools=list_tools, on_call_tool=call_tool)
extensions = None if ui_settings is None else [advertise(EXTENSION_ID, ui_settings)]
async with Client(server, extensions=extensions) as client:
await client.call_tool("probe", {})
return observed[0]
@pytest.mark.parametrize(
("extensions", "expected"),
("ui_settings", "expected"),
[
pytest.param({EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}, True, id="html-mime-listed"),
pytest.param({EXTENSION_ID: {"mimeTypes": (APP_MIME_TYPE,)}}, True, id="in-process-tuple-mime-types"),
pytest.param({"mimeTypes": [APP_MIME_TYPE]}, True, id="html-mime-listed"),
pytest.param({"mimeTypes": (APP_MIME_TYPE,)}, True, id="in-process-tuple-mime-types"),
pytest.param(None, False, id="extension-not-declared"),
pytest.param({EXTENSION_ID: {"mimeTypes": ["application/x-other"]}}, False, id="html-mime-not-offered"),
pytest.param({EXTENSION_ID: {}}, False, id="mime-types-key-missing"),
pytest.param({"mimeTypes": ["application/x-other"]}, False, id="html-mime-not-offered"),
pytest.param({}, False, id="mime-types-key-missing"),
],
)
async def test_client_supports_apps_from_lowlevel_request_context(
extensions: dict[str, dict[str, Any]] | None, expected: bool
ui_settings: dict[str, Any] | None, expected: bool
) -> None:
"""ext-apps: `client_supports_apps` is `True` only when the client declared the ui
extension AND listed `text/html;profile=mcp-app` in its `mimeTypes` settings a
required field, so its absence means unsupported (the reference SDK's check is
`uiCap?.mimeTypes?.includes(...)`)."""
assert await _observed_client_supports_apps(extensions) is expected
assert await _observed_client_supports_apps(ui_settings) is expected
def test_apps_tool_rejects_non_ui_resource_uri() -> None:
+3 -2
View File
@@ -13,6 +13,7 @@ import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp.client import advertise
from mcp.client.client import Client
from mcp.server import Server, ServerRequestContext
from mcp.server.extension import Extension
@@ -82,7 +83,7 @@ async def test_server_accepts_capability_for_client_advertised_extension() -> No
return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})])
server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools)
async with Client(server, extensions={_EXTENSION_ID: {"mimeTypes": ["text/html"]}}) as client:
async with Client(server, extensions=[advertise(_EXTENSION_ID, {"mimeTypes": ["text/html"]})]) as client:
await client.call_tool("probe", {})
assert supported == [True]
@@ -105,7 +106,7 @@ async def test_server_rejects_capability_for_undeclared_extension() -> None:
return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})])
server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools)
async with Client(server, extensions={_EXTENSION_ID: {"mimeTypes": ["text/html"]}}) as client:
async with Client(server, extensions=[advertise(_EXTENSION_ID, {"mimeTypes": ["text/html"]})]) as client:
await client.call_tool("probe", {})
assert supported == [False]
+56
View File
@@ -0,0 +1,56 @@
"""The extension-identifier grammar in `mcp.shared.extension`, shared by server and client."""
from typing import Any
import pytest
import mcp.server.extension
import mcp.shared.extension
from mcp.shared.extension import validate_extension_identifier
def test_server_extension_module_reexports_shared_validator() -> None:
"""SDK-defined: `mcp.server.extension` re-exports the shared validator as the same function object."""
assert mcp.server.extension.validate_extension_identifier is mcp.shared.extension.validate_extension_identifier
@pytest.mark.parametrize(
"identifier",
[
"io.modelcontextprotocol/ui",
"com.example/my_ext",
"com.x-y.z2/n.a-b_c",
"example/x",
"a/b",
"com.example/9start",
],
)
def test_grammar_conformant_extension_identifiers_are_accepted(identifier: str) -> None:
"""Spec `_meta` key grammar: conformant `vendor-prefix/name` identifiers are accepted."""
validate_extension_identifier(identifier, owner="T")
@pytest.mark.parametrize(
"identifier",
[
"noprefix",
"-foo/bar",
".leading/x",
"a..b/x",
"foo-/x",
"9foo/x",
"foo/-bar",
"foo/bar-",
"foo/",
"/bar",
"foo/ba r",
"io.modelcontextprotocol/ui\n",
"",
None,
42,
],
)
def test_malformed_extension_identifiers_are_rejected(identifier: Any) -> None:
"""Spec `_meta` key grammar: malformed prefixes, malformed names, and non-strings are rejected."""
with pytest.raises(TypeError):
validate_extension_identifier(identifier, owner="T")
+37
View File
@@ -0,0 +1,37 @@
"""`Request.name_param`: the wire-params key a request type declares for `Mcp-Name` emission."""
from typing import Literal
import mcp_types as types
from mcp_types import CallToolRequest, PingRequest, Request
class _VendorParams(types.RequestParams):
task_id: str
class _VendorRequest(Request[_VendorParams, Literal["vendor/tasks/get"]]):
method: Literal["vendor/tasks/get"] = "vendor/tasks/get"
name_param = "taskId"
def test_request_base_declares_no_name_param() -> None:
assert Request.name_param is None
def test_core_request_types_inherit_none() -> None:
assert CallToolRequest.name_param is None
assert PingRequest.name_param is None
def test_subclass_overrides_by_bare_assignment() -> None:
"""Subclasses set `name_param` by bare assignment; the override is class-local."""
assert _VendorRequest.name_param == "taskId"
assert Request.name_param is None
def test_name_param_is_not_a_pydantic_field() -> None:
request = _VendorRequest(params=_VendorParams(task_id="t-1"))
assert "name_param" not in _VendorRequest.model_fields
dumped = request.model_dump(by_alias=True, mode="json", exclude_none=True)
assert dumped == {"method": "vendor/tasks/get", "params": {"taskId": "t-1"}}