Add a client extension API (#3034)
This commit is contained in:
+108
-18
@@ -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
@@ -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`
|
||||
|
||||
|
||||
@@ -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')]
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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')]
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 server→client 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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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"}))
|
||||
@@ -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", {})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"}))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
@@ -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"}}
|
||||
Reference in New Issue
Block a user