Add a client extension API (#3034)

This commit is contained in:
Max
2026-06-30 21:31:02 +01:00
committed by GitHub
parent 7322ca56f4
commit 4df609119f
37 changed files with 3410 additions and 180 deletions
+573
View File
@@ -0,0 +1,573 @@
"""`Client` + `ClientExtension` integration: extension declarations fold into the session at
construction, and `call_tool` drives claim resolvers transparently against real `MCPServer`s.
"""
import logging
from collections.abc import Awaitable, Callable, Sequence
from typing import Any, Literal, cast
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, Result, TextContent
from mcp_types.version import LATEST_MODERN_VERSION
from pydantic import BaseModel
from typing_extensions import assert_type
from mcp.client import ClaimContext, ClientExtension, NotificationBinding, ResultClaim, advertise
from mcp.client.client import Client
from mcp.client.session import ClientRequestContext, _CallToolResultAdapter
from mcp.server import Server, ServerRequestContext
from mcp.server.context import CallNext, HandlerResult
from mcp.server.extension import Extension
from mcp.server.mcpserver import Context, MCPServer
pytestmark = pytest.mark.anyio
_VOUCHER_EXT = "com.example/voucher"
_RIVAL_EXT = "com.example/rival"
_NAME_SCHEMA = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}
def _name_elicitation() -> types.ElicitRequest:
return types.ElicitRequest(
params=types.ElicitRequestFormParams(message="What is your name?", requested_schema=_NAME_SCHEMA)
)
class VoucherResult(Result):
"""The claimed `tools/call` shape, tagged `voucher`, carrying a vendor top-level field."""
result_type: Literal["voucher"] = "voucher"
voucher_code: str | None = None
_Resolver = Callable[[VoucherResult, ClaimContext], Awaitable[CallToolResult]]
class _VoucherExtension(ClientExtension):
"""Client half: claims the `voucher` tag with the supplied resolver."""
identifier = _VOUCHER_EXT
def __init__(self, resolve: _Resolver) -> None:
self._resolve = resolve
def claims(self) -> Sequence[ResultClaim[Any]]:
return [ResultClaim(result_type="voucher", model=VoucherResult, resolve=self._resolve)]
class _VoucherIssuer(Extension):
"""Server half: rewrites every `tools/call` result into the vendor-claimed shape."""
identifier = _VOUCHER_EXT
async def intercept_tool_call(
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
) -> HandlerResult:
return {"resultType": "voucher", "voucherCode": "v-42"}
class _TwoRoundVoucherIssuer(Extension):
"""Server half: demands input on the first round, then issues the claimed shape."""
identifier = _VOUCHER_EXT
async def intercept_tool_call(
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
) -> HandlerResult:
if params.input_responses is None:
return types.InputRequiredResult(input_requests={"user_name": _name_elicitation()})
return {"resultType": "voucher", "voucherCode": "after-input"}
def _voucher_server(issuer: Extension | None = None) -> MCPServer:
"""An `MCPServer` whose `issue` tool the server extension rewrites into the claimed shape."""
server = MCPServer("vouchers", extensions=[issuer if issuer is not None else _VoucherIssuer()])
@server.tool()
def issue() -> CallToolResult:
"""Issue a voucher."""
raise NotImplementedError # the server extension short-circuits before the tool runs
return server
def _structured_voucher_server() -> MCPServer:
"""Like `_voucher_server`, but `issue` declares an output schema (`-> str`)."""
server = MCPServer("vouchers", extensions=[_VoucherIssuer()])
@server.tool()
def issue() -> str:
"""Issue a voucher."""
raise NotImplementedError # the server extension short-circuits before the tool runs
return server
def _add_server() -> MCPServer:
"""A plain claim-less server with one ordinary tool."""
server = MCPServer("plain")
@server.tool()
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
return server
# Construction-time validation
class _CouponResult(Result):
result_type: Literal["coupon"] = "coupon"
async def _unreachable_coupon_resolve(claimed: _CouponResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError # the wrong resolver for a voucher; must never run
class _CouponExtension(ClientExtension):
identifier = "com.example/coupons"
def claims(self) -> Sequence[ResultClaim[Any]]:
return [ResultClaim(result_type="coupon", model=_CouponResult, resolve=_unreachable_coupon_resolve)]
class _SelfConflictingClaims(ClientExtension):
identifier = "com.example/twice"
def claims(self) -> Sequence[ResultClaim[Any]]:
return [
ResultClaim(result_type="twice", model=_TwiceResult, resolve=_unreachable_twice_resolve),
ResultClaim(result_type="twice", model=_TwiceResult, resolve=_unreachable_twice_resolve),
]
class _TwiceResult(Result):
result_type: Literal["twice"] = "twice"
async def _unreachable_twice_resolve(claimed: _TwiceResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError
def test_mapping_extensions_get_the_migration_error() -> None:
"""SDK-defined: the replaced dict form fails with a message naming the new shape."""
with pytest.raises(TypeError) as exc_info:
Client(_add_server(), extensions=cast("Sequence[ClientExtension]", {"com.example/ui": {}}))
assert str(exc_info.value) == snapshot(
"extensions= takes a sequence of ClientExtension instances. The mapping form was "
"replaced: use advertise(identifier, settings) for advertise-only entries"
)
def test_one_extension_claiming_a_tag_twice_reads_as_one_owner() -> None:
"""SDK-defined: a self-conflict names the one extension once, not as a pair."""
with pytest.raises(ValueError) as exc_info:
Client(_add_server(), extensions=[_SelfConflictingClaims()])
assert str(exc_info.value) == snapshot(
"extension 'com.example/twice' claims resultType 'twice'; a wire tag can have only one resolver"
)
def test_bare_extension_instance_is_rejected_with_the_fix_named() -> None:
"""SDK-defined: an instance whose class never set `identifier` fails construction naming the type and the fix."""
with pytest.raises(ValueError) as exc_info:
Client(_add_server(), extensions=[ClientExtension()])
assert str(exc_info.value) == snapshot(
"ClientExtension has no `identifier`; a ClientExtension must set the `identifier` "
"class attribute (or assign one in `__init__`) before it can be used"
)
class _SelfAssignedBadId(ClientExtension):
"""Assigns a malformed identifier in `__init__`, invisible at class definition."""
def __init__(self) -> None:
self.identifier = "not-prefixed"
def test_invalid_per_instance_identifier_raises_the_validators_error() -> None:
"""SDK-defined: per-instance identifiers are validated when the Client consumes the extension."""
with pytest.raises(TypeError) as exc_info:
Client(_add_server(), extensions=[_SelfAssignedBadId()])
assert str(exc_info.value) == snapshot(
"_SelfAssignedBadId.identifier must be a `vendor-prefix/name` string "
"(reverse-DNS prefix required), got 'not-prefixed'"
)
def test_duplicate_extension_identifiers_are_rejected_naming_the_identifier() -> None:
"""SDK-defined: one identifier cannot appear twice across the extensions sequence."""
with pytest.raises(ValueError) as exc_info:
Client(_add_server(), extensions=[advertise(_VOUCHER_EXT), advertise(_VOUCHER_EXT, {"a": 1})])
assert str(exc_info.value) == snapshot("extension identifier 'com.example/voucher' is passed more than once")
async def _unreachable_resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError
class _RivalVoucherExtension(ClientExtension):
identifier = _RIVAL_EXT
def claims(self) -> Sequence[ResultClaim[Any]]:
return [ResultClaim(result_type="voucher", model=VoucherResult, resolve=_unreachable_resolve)]
def test_conflicting_claims_across_extensions_name_both_owners() -> None:
"""SDK-defined: two extensions claiming the same tag fail at construction with both owners named."""
with pytest.raises(ValueError) as exc_info:
Client(_add_server(), extensions=[_VoucherExtension(_unreachable_resolve), _RivalVoucherExtension()])
assert str(exc_info.value) == snapshot(
"extensions 'com.example/voucher' and 'com.example/rival' both claim resultType "
"'voucher'; a wire tag can have only one resolver"
)
class _EventParams(BaseModel):
seq: int
async def _unreachable_handler(params: _EventParams) -> None:
raise NotImplementedError
class _ObserverA(ClientExtension):
identifier = "com.example/observer-a"
def notifications(self) -> Sequence[NotificationBinding[Any]]:
return [
NotificationBinding(
method="notifications/vendor/event", params_type=_EventParams, handler=_unreachable_handler
)
]
class _ObserverB(ClientExtension):
identifier = "com.example/observer-b"
def notifications(self) -> Sequence[NotificationBinding[Any]]:
return [
NotificationBinding(
method="notifications/vendor/event", params_type=_EventParams, handler=_unreachable_handler
)
]
def test_conflicting_notification_bindings_name_both_owners() -> None:
"""SDK-defined: two extensions binding the same notification method fail with both owners named."""
with pytest.raises(ValueError) as exc_info:
Client(_add_server(), extensions=[_ObserverA(), _ObserverB()])
assert str(exc_info.value) == snapshot(
"extensions 'com.example/observer-a' and 'com.example/observer-b' both bind "
"notification method 'notifications/vendor/event'; a method can have only one observer"
)
# settings() consumption
class _CountedResult(Result):
result_type: Literal["counted"] = "counted"
async def _unreachable_counted_resolve(claimed: _CountedResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError
class _CountingSettings(ClientExtension):
identifier = "com.example/counted"
def __init__(self) -> None:
self.reads = 0
self.claims_reads = 0
self.notifications_reads = 0
def settings(self) -> dict[str, Any]:
self.reads += 1
return {"read": self.reads}
def claims(self) -> Sequence[ResultClaim[Any]]:
self.claims_reads += 1
return [ResultClaim(result_type="counted", model=_CountedResult, resolve=_unreachable_counted_resolve)]
def notifications(self) -> Sequence[NotificationBinding[Any]]:
self.notifications_reads += 1
return [
NotificationBinding(method="notifications/counted", params_type=_EventParams, handler=_unreachable_handler)
]
async def test_declarations_are_read_exactly_once_at_construction() -> None:
"""SDK-defined: each declaration method is read exactly once, at Client construction, never again."""
extension = _CountingSettings()
client = Client(_add_server(), extensions=[extension])
assert (extension.reads, extension.claims_reads, extension.notifications_reads) == (1, 1, 1)
with anyio.fail_after(5):
async with client:
await client.call_tool("add", {"a": 1, "b": 2})
await client.call_tool("add", {"a": 3, "b": 4})
assert (extension.reads, extension.claims_reads, extension.notifications_reads) == (1, 1, 1)
async def test_settings_dict_is_held_by_reference_not_copied() -> None:
"""SDK-defined: the settings dict is held by reference, so mutating it before connect changes the ad."""
observed: list[dict[str, dict[str, Any]] | None] = []
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "probe"
assert ctx.session.client_params is not None
observed.append(ctx.session.client_params.capabilities.extensions)
return CallToolResult(content=[])
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})])
server = Server("probe", on_call_tool=call_tool, on_list_tools=list_tools)
settings = {"tier": "bronze"}
client = Client(server, extensions=[advertise("com.example/loyalty", settings)])
settings["tier"] = "gold"
with anyio.fail_after(5):
async with client:
await client.call_tool("probe", {})
assert observed == [{"com.example/loyalty": {"tier": "gold"}}]
# extensions=None stays byte-identical
@pytest.mark.parametrize("extensions", [None, ()], ids=["none", "empty"])
async def test_no_extensions_keeps_tools_call_parsing_byte_identical(
extensions: Sequence[ClientExtension] | None,
) -> None:
"""SDK-defined: `extensions=None` and an empty sequence leave the session exactly as a claim-less client's."""
with anyio.fail_after(5):
async with Client(_add_server(), extensions=extensions) as client:
assert client.session._call_tool_adapter is _CallToolResultAdapter
result = await client.call_tool("add", {"a": 1, "b": 2})
assert result.structured_content == {"result": 3}
# The transparent claim path
async def test_claimed_result_resolves_transparently_to_the_resolvers_result() -> None:
"""A claimed shape never surfaces: the resolver gets the parsed model and `call_tool` returns its product."""
received: list[VoucherResult] = []
produced: list[CallToolResult] = []
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
received.append(claimed)
product = CallToolResult(content=[TextContent(text=f"honored {claimed.voucher_code}")])
produced.append(product)
return product
with anyio.fail_after(5):
async with Client(_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client:
result = await client.call_tool("issue", {})
assert_type(result, CallToolResult)
assert [claimed.voucher_code for claimed in received] == ["v-42"]
assert result is produced[0]
assert result.content == [TextContent(text="honored v-42")]
async def test_claimed_shape_routes_to_its_owning_extensions_resolver() -> None:
"""With two claim-bearing extensions registered, the parsed shape runs its owner's resolver only."""
received: list[VoucherResult] = []
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
received.append(claimed)
return CallToolResult(content=[TextContent(text="routed")])
extensions = [_CouponExtension(), _VoucherExtension(resolve)]
with anyio.fail_after(5):
async with Client(_voucher_server(), extensions=extensions) as client:
result = await client.call_tool("issue", {})
assert [claimed.voucher_code for claimed in received] == ["v-42"]
assert result.content == [TextContent(text="routed")]
async def test_resolver_product_gets_the_direct_paths_output_schema_revalidation() -> None:
"""The resolver's product is revalidated against the tool's output schema exactly like a direct result."""
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
return CallToolResult(content=[TextContent(text="unstructured")])
async with Client(_structured_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client:
with anyio.fail_after(5), pytest.raises(RuntimeError) as exc_info:
await client.call_tool("issue", {})
assert str(exc_info.value) == snapshot("Tool issue has an output schema but did not return structured content")
async def test_resolver_error_result_is_returned_not_raised() -> None:
"""An `isError` resolver product skips output-schema revalidation and comes back as-is."""
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
return CallToolResult(content=[TextContent(text="voucher printer on fire")], is_error=True)
with anyio.fail_after(5):
async with Client(_structured_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client:
result = await client.call_tool("issue", {})
assert result.is_error
assert result.content == [TextContent(text="voucher printer on fire")]
async def test_resolver_receives_the_calls_claim_context() -> None:
"""`ClaimContext` carries the client's own session object, the tool name, and the per-call read timeout."""
contexts: list[ClaimContext] = []
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
contexts.append(ctx)
return CallToolResult(content=[])
with anyio.fail_after(5):
async with Client(_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client:
await client.call_tool("issue", {}, read_timeout_seconds=7.0)
[ctx] = contexts
assert ctx.session is client.session
assert ctx.tool_name == "issue"
assert ctx.read_timeout_seconds == 7.0
class _VoucherRefused(Exception):
"""Extension-owned error vocabulary."""
async def test_resolver_exception_propagates_untouched() -> None:
"""A resolver exception reaches the `call_tool` caller as the very object raised, unwrapped."""
refusal = _VoucherRefused("the voucher is refused")
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
raise refusal
async with Client(_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client:
with anyio.fail_after(5), pytest.raises(_VoucherRefused) as exc_info:
await client.call_tool("issue", {})
assert exc_info.value is refusal
# Unclaimed results with extensions present
async def test_unclaimed_result_flows_through_unchanged_with_extensions_present() -> None:
"""An ordinary `CallToolResult` is untouched by the claim machinery; the resolver never runs."""
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError # this server never produces a claimed shape
with anyio.fail_after(5):
async with Client(_add_server(), extensions=[_VoucherExtension(resolve)]) as client:
result = await client.call_tool("add", {"a": 1, "b": 2})
assert result.structured_content == {"result": 3}
async def test_input_required_then_plain_result_keeps_the_auto_loop_working() -> None:
"""With a claim-bearing extension present, the input_required auto loop on an unclaimed tool is unchanged."""
server = MCPServer("mrtr")
@server.tool()
async def greet(ctx: Context) -> str | types.InputRequiredResult:
responses = ctx.input_responses
if responses and "user_name" in responses:
answer = responses["user_name"]
assert isinstance(answer, types.ElicitResult)
assert answer.content is not None
return f"Hello, {answer.content['name']}!"
return types.InputRequiredResult(input_requests={"user_name": _name_elicitation()})
async def elicitation_callback(
context: ClientRequestContext, params: types.ElicitRequestParams
) -> types.ElicitResult | types.ErrorData:
return types.ElicitResult(action="accept", content={"name": "Ada"})
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError # this server never produces a claimed shape
with anyio.fail_after(5):
async with Client(
server, elicitation_callback=elicitation_callback, extensions=[_VoucherExtension(resolve)]
) as client:
result = await client.call_tool("greet")
assert result.content == [TextContent(text="Hello, Ada!")]
# The multi-round-trip + claimed interplay
async def test_input_required_then_claimed_result_on_retry_resolves_transparently() -> None:
"""A call that demands input first and returns a claimed shape on the retry still resolves transparently."""
prompted: list[str] = []
received: list[VoucherResult] = []
async def elicitation_callback(
context: ClientRequestContext, params: types.ElicitRequestParams
) -> types.ElicitResult | types.ErrorData:
assert isinstance(params, types.ElicitRequestFormParams)
prompted.append(params.message)
return types.ElicitResult(action="accept", content={"name": "Ada"})
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
received.append(claimed)
return CallToolResult(content=[TextContent(text=f"honored {claimed.voucher_code}")])
server = _voucher_server(issuer=_TwoRoundVoucherIssuer())
with anyio.fail_after(5):
async with Client(
server, elicitation_callback=elicitation_callback, extensions=[_VoucherExtension(resolve)]
) as client:
result = await client.call_tool("issue", {})
assert prompted == ["What is your name?"]
assert [claimed.voucher_code for claimed in received] == ["after-input"]
assert result.content == [TextContent(text="honored after-input")]
# Notification bindings fold into the session
class _CoreMethodObserver(ClientExtension):
"""Binds a method the modern core tables already define."""
identifier = "com.example/observer"
def notifications(self) -> Sequence[NotificationBinding[Any]]:
return [
NotificationBinding(method="notifications/message", params_type=_EventParams, handler=_unreachable_handler)
]
async def test_notification_bindings_fold_into_the_session(caplog: pytest.LogCaptureFixture) -> None:
"""The Client threads extension bindings into its session; a core-known binding draws the one-time warning."""
with caplog.at_level(logging.WARNING, logger="client"):
async with Client(_add_server(), extensions=[_CoreMethodObserver()]):
pass
expected = f"notification binding for 'notifications/message' will never fire at {LATEST_MODERN_VERSION}"
assert caplog.text.count(expected) == 1
+379
View File
@@ -0,0 +1,379 @@
"""Construction-time tests for `mcp.client.extension`; no session is ever opened."""
from dataclasses import FrozenInstanceError
from typing import Any, Literal, cast
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, InputRequiredResult, Result
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from pydantic import AliasChoices, AliasPath, BaseModel, Field
from pydantic.fields import FieldInfo
from mcp.client.extension import (
ClaimContext,
ClientExtension,
NotificationBinding,
ResultClaim,
_wire_keys,
advertise,
)
class _TaskResult(Result):
result_type: Literal["task"] = "task"
task_id: str = "t-1"
class _UntaggedResult(Result):
"""No `result_type` field at all."""
class _PlainStringTagResult(Result):
result_type: str = "task"
class _OtherTagResult(Result):
result_type: Literal["other"] = "other"
class _ClaimedCallToolResult(CallToolResult):
"""A core-result subclass; rejected as a claim model regardless of its tag."""
class _ClaimedInputRequiredResult(InputRequiredResult):
"""A core-result subclass; rejected as a claim model regardless of its tag."""
async def _resolve(result: Result, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError
def _claim(model: type[Result] = _TaskResult, **kwargs: Any) -> ResultClaim[Result]:
return ResultClaim(result_type="task", model=model, resolve=_resolve, **kwargs)
def test_claim_with_literal_discriminated_model_constructs() -> None:
"""SDK-defined: a model tagged with the claimed Literal constructs, defaulting to `tools/call` everywhere."""
claim = ResultClaim(result_type="task", model=_TaskResult, resolve=_resolve)
assert claim.result_type == "task"
assert claim.model is _TaskResult
assert claim.resolve is _resolve
assert claim.method == "tools/call"
assert claim.protocol_versions is None
def test_claim_accepts_modern_protocol_versions() -> None:
"""SDK-defined: a non-None `protocol_versions` subset of the modern revisions is accepted."""
versions = frozenset(MODERN_PROTOCOL_VERSIONS)
claim = _claim(protocol_versions=versions)
assert claim.protocol_versions == versions
def test_claim_rejects_core_result_type_vocabulary() -> None:
"""SDK-defined: a claim cannot re-key the core tags 'complete' and 'input_required'."""
messages: dict[str, str] = {}
for result_type in ("complete", "input_required"):
with pytest.raises(ValueError) as exc_info:
ResultClaim(result_type=result_type, model=_TaskResult, resolve=_resolve)
messages[result_type] = str(exc_info.value)
assert messages == snapshot(
{
"complete": "resultType 'complete' is core protocol vocabulary",
"input_required": "resultType 'input_required' is core protocol vocabulary",
}
)
@pytest.mark.parametrize("model", [_ClaimedCallToolResult, _ClaimedInputRequiredResult])
def test_claim_rejects_model_subclassing_core_result_types(model: type[Result]) -> None:
"""SDK-defined: a claim model subclassing a core result type is rejected; it would bypass claim routing."""
with pytest.raises(ValueError) as exc_info:
_claim(model=model)
assert str(exc_info.value) == snapshot("claim models must not subclass core result types")
def test_claim_rejects_model_without_result_type_field() -> None:
"""SDK-defined: the claim model must declare the discriminating `result_type` field."""
with pytest.raises(ValueError) as exc_info:
_claim(model=_UntaggedResult)
assert str(exc_info.value) == snapshot("_UntaggedResult.result_type must be Literal['task']")
def test_claim_rejects_plain_str_result_type_field() -> None:
"""SDK-defined: the model's `result_type` must be a Literal of the claimed tag, not a plain `str`."""
with pytest.raises(ValueError) as exc_info:
_claim(model=_PlainStringTagResult)
assert str(exc_info.value) == snapshot("_PlainStringTagResult.result_type must be Literal['task']")
def test_claim_rejects_mismatched_result_type_literal() -> None:
"""SDK-defined: the model's Literal tag must equal the claim's `result_type`."""
with pytest.raises(ValueError) as exc_info:
_claim(model=_OtherTagResult)
assert str(exc_info.value) == snapshot("_OtherTagResult.result_type must be Literal['task']")
class _NotAResult(BaseModel):
result_type: Literal["plain"] = "plain"
class _ReservedAliasResult(Result):
result_type: Literal["clash"] = "clash"
request_state: dict[str, Any] = {}
def test_claim_rejects_model_not_subclassing_result() -> None:
"""SDK-defined: a plain BaseModel cannot be a claim model; the session returns `Result` values."""
with pytest.raises(ValueError) as exc_info:
ResultClaim(result_type="plain", model=cast("type[Result]", _NotAResult), resolve=_resolve)
assert str(exc_info.value) == snapshot("_NotAResult must subclass mcp_types.Result")
def test_claim_rejects_model_aliasing_core_surface_fields() -> None:
"""SDK-defined: a field aliasing requestState or inputRequests would fail core pre-validation."""
with pytest.raises(ValueError) as exc_info:
ResultClaim(result_type="clash", model=_ReservedAliasResult, resolve=_resolve)
assert str(exc_info.value) == snapshot(
"_ReservedAliasResult.request_state aliases 'requestState', a typed field of the core "
"result surface; a colliding value would fail core validation before the claim adapter runs"
)
class _ValidationAliasResult(Result):
result_type: Literal["va"] = "va"
vendor_state: dict[str, Any] | None = Field(default=None, validation_alias="requestState")
class _SerializationAliasResult(Result):
result_type: Literal["sa"] = "sa"
vendor_state: dict[str, Any] | None = Field(default=None, serialization_alias="inputRequests")
class _AliasChoicesResult(Result):
result_type: Literal["ac"] = "ac"
vendor_state: dict[str, Any] | None = Field(
default=None, validation_alias=AliasChoices("vendorKey", "requestState")
)
class _AliasPathResult(Result):
result_type: Literal["ap"] = "ap"
vendor_state: dict[str, Any] | None = Field(
default=None, validation_alias=AliasChoices(AliasPath("requestState", "nested"))
)
def test_wire_keys_for_a_bare_field_is_just_its_name() -> None:
"""SDK-defined: a field with no aliases reads and writes only its own name."""
assert _wire_keys("plain", FieldInfo(annotation=str)) == frozenset({"plain"})
def test_claim_rejects_reserved_aliases_in_every_alias_form() -> None:
"""SDK-defined: validation_alias, serialization_alias, and AliasChoices routes to a reserved key are all caught."""
messages: dict[str, str] = {}
for model in (_ValidationAliasResult, _SerializationAliasResult, _AliasChoicesResult, _AliasPathResult):
with pytest.raises(ValueError) as exc_info:
ResultClaim(result_type=model.model_fields["result_type"].default, model=model, resolve=_resolve)
messages[model.__name__] = str(exc_info.value)
assert messages == snapshot(
{
"_ValidationAliasResult": "_ValidationAliasResult.vendor_state aliases "
"'requestState', a typed field of the core result surface; a colliding value would fail "
"core validation before the claim adapter runs",
"_SerializationAliasResult": "_SerializationAliasResult.vendor_state aliases "
"'inputRequests', a typed field of the core result surface; a colliding value would fail "
"core validation before the claim adapter runs",
"_AliasChoicesResult": "_AliasChoicesResult.vendor_state aliases 'requestState', a typed field of the core "
"result surface; a colliding value would fail core validation before the claim adapter runs",
"_AliasPathResult": "_AliasPathResult.vendor_state aliases "
"'requestState', a typed field of the core result surface; a colliding value would fail "
"core validation before the claim adapter runs",
}
)
def test_claim_rejects_method_outside_the_closed_verb_set() -> None:
"""SDK-defined: claims attach to `tools/call` only, even for values that dodge the static Literal gate."""
with pytest.raises(ValueError) as exc_info:
_claim(method=cast("Literal['tools/call']", "prompts/get"))
assert str(exc_info.value) == snapshot("claims attach to ['tools/call'] only; got method 'prompts/get'")
def test_claim_rejects_empty_protocol_versions() -> None:
"""SDK-defined: an empty version set is rejected; `None` is the spelling for every modern version."""
with pytest.raises(ValueError) as exc_info:
_claim(protocol_versions=frozenset())
assert str(exc_info.value) == snapshot("empty protocol_versions could never activate; use None for all")
def test_claim_rejects_non_modern_protocol_versions() -> None:
"""SDK-defined: a non-None version set must be a subset of the modern protocol revisions."""
messages: list[str] = []
for versions in (
frozenset({"2025-11-25"}),
frozenset({"2026-07-28", "2025-11-25"}),
frozenset({"never-a-version"}),
):
with pytest.raises(ValueError) as exc_info:
_claim(protocol_versions=versions)
messages.append(str(exc_info.value))
assert messages == snapshot(
[
"protocol_versions ['2025-11-25'] are not modern protocol revisions; claimed shapes "
"cannot be delivered on a legacy wire (None means every modern version)",
"protocol_versions ['2025-11-25'] are not modern protocol revisions; claimed shapes "
"cannot be delivered on a legacy wire (None means every modern version)",
"protocol_versions ['never-a-version'] are not modern protocol revisions; claimed shapes "
"cannot be delivered on a legacy wire (None means every modern version)",
]
)
def test_result_claim_is_frozen() -> None:
"""SDK-defined: claims are immutable; mutating one after construction raises."""
claim = _claim()
with pytest.raises(FrozenInstanceError):
setattr(claim, "result_type", "other") # direct assignment is also a type error
class _TaskNotificationParams(BaseModel):
task_id: str
async def _on_task(params: _TaskNotificationParams) -> None:
raise NotImplementedError
def test_notification_binding_constructs() -> None:
"""SDK-defined: a binding is a bare declaration with no construction-time validation."""
binding = NotificationBinding(method="notifications/tasks", params_type=_TaskNotificationParams, handler=_on_task)
assert binding.method == "notifications/tasks"
assert binding.params_type is _TaskNotificationParams
assert binding.handler is _on_task
def test_notification_binding_accepts_core_known_method() -> None:
"""SDK-defined: deliberately no spec-table check at construction, so packages survive core adopting a method."""
binding = NotificationBinding(
method="notifications/progress", params_type=_TaskNotificationParams, handler=_on_task
)
assert binding.method == "notifications/progress"
def test_notification_binding_is_frozen() -> None:
"""SDK-defined: bindings are immutable; mutating one after construction raises."""
binding = NotificationBinding(method="notifications/tasks", params_type=_TaskNotificationParams, handler=_on_task)
with pytest.raises(FrozenInstanceError):
setattr(binding, "method", "notifications/other") # direct assignment is also a type error
def test_extension_defaults_advertise_nothing() -> None:
"""SDK-defined: a minimal subclass advertises empty settings, no claims, and no bindings."""
class _MinimalExt(ClientExtension):
identifier = "com.example/minimal"
ext = _MinimalExt()
assert ext.settings() == {}
assert ext.claims() == ()
assert ext.notifications() == ()
@pytest.mark.parametrize(
"identifier",
[
"io.modelcontextprotocol/ui",
"com.example/my_ext",
"com.x-y.z2/n.a-b_c",
"example/x",
"a/b",
"com.example/9start",
],
)
def test_grammar_conformant_identifiers_accepted_at_class_definition(identifier: str) -> None:
"""Spec `_meta` key grammar: conformant `vendor-prefix/name` identifiers are accepted."""
cls = type("_GoodExt", (ClientExtension,), {"identifier": identifier})
assert cls.identifier == identifier
@pytest.mark.parametrize(
"identifier",
[
"noprefix",
"-foo/bar",
".leading/x",
"a..b/x",
"foo-/x",
"9foo/x",
"foo/-bar",
"foo/bar-",
"foo/",
"/bar",
"foo/ba r",
"io.modelcontextprotocol/ui\n",
"",
42,
],
)
def test_malformed_identifier_rejected_at_class_definition(identifier: Any) -> None:
"""SDK-defined: the SEP-2133 `vendor-prefix/name` grammar is enforced the moment the subclass is defined."""
with pytest.raises(TypeError):
type("_BadExt", (ClientExtension,), {"identifier": identifier})
def test_subclass_without_identifier_allowed_at_definition() -> None:
"""SDK-defined: a subclass with no class-level `identifier` is allowed; validation waits for consumption."""
class _AbstractishExt(ClientExtension):
"""Intermediate base; concrete subclasses supply the identifier."""
class _ConcreteExt(_AbstractishExt):
identifier = "com.example/concrete"
assert _ConcreteExt.identifier == "com.example/concrete"
def test_advertise_serves_captured_settings() -> None:
"""SDK-defined: `advertise()` returns an ad-only extension serving the captured settings."""
ext = advertise("com.example/flags", {"enabled": True})
assert isinstance(ext, ClientExtension)
assert ext.identifier == "com.example/flags"
assert ext.settings() == {"enabled": True}
assert ext.claims() == ()
assert ext.notifications() == ()
def test_advertise_defaults_to_empty_settings() -> None:
"""SDK-defined: omitting settings advertises the extension with an empty map."""
ext = advertise("com.example/flags")
assert ext.settings() == {}
@pytest.mark.parametrize("identifier", ["noprefix", "foo/", ""])
def test_advertise_validates_identifier_eagerly(identifier: str) -> None:
"""SDK-defined: `advertise()` validates the identifier eagerly, at the call site."""
with pytest.raises(TypeError):
advertise(identifier)
+251
View File
@@ -0,0 +1,251 @@
"""`ClientSession.send_request` mirrors `Request.name_param` into the `Mcp-Name`
header on send paths the core `NAME_BEARING_METHODS` table does not cover. The
vendor sends also pin the widened `send_request` typing (no cast needed)."""
from collections.abc import Mapping
from typing import Any, Literal
import anyio
import anyio.abc
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CallToolResult,
Implementation,
ListToolsResult,
Request,
ServerCapabilities,
TextContent,
Tool,
)
from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
from mcp.client.session import ClientSession
from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest
from mcp.shared.inbound import MCP_NAME_HEADER, MCP_PROTOCOL_VERSION_HEADER, encode_header_value
class _RecordingDispatcher:
"""Records `send_raw_request` opts and answers with canned per-method results."""
def __init__(self) -> None:
self.calls: list[tuple[str, CallOptions]] = []
async def run(
self,
on_request: OnRequest,
on_notify: OnNotify,
*,
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
) -> None:
task_status.started()
await anyio.sleep_forever()
async def send_raw_request(
self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None
) -> dict[str, Any]:
self.calls.append((method, opts or {}))
if method == "tools/call":
return CallToolResult(content=[TextContent(type="text", text="ok")]).model_dump(
by_alias=True, mode="json", exclude_none=True
)
if method == "tools/list":
return ListToolsResult(tools=[Tool(name="my-tool", input_schema={"type": "object"})]).model_dump(
by_alias=True, mode="json", exclude_none=True
)
return {}
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
raise NotImplementedError
class _GetWidgetParams(types.RequestParams):
widget_id: str
class _GetWidgetRequest(Request[_GetWidgetParams, Literal["vendor/widgets/get"]]):
method: Literal["vendor/widgets/get"] = "vendor/widgets/get"
name_param = "widgetId"
class _RawWidgetRequest(Request[dict[str, Any], Literal["vendor/widgets/get"]]):
"""Same wire shape with untyped params, so tests can omit or mistype the name value."""
method: Literal["vendor/widgets/get"] = "vendor/widgets/get"
name_param = "widgetId"
class _ShadowCallToolRequest(Request[dict[str, Any], Literal["tools/call"]]):
"""A vendor type declaring `name_param` for a method the core table already covers."""
method: Literal["tools/call"] = "tools/call"
name_param = "customKey"
class _PlainVendorRequest(Request[dict[str, Any], Literal["vendor/widgets/list"]]):
method: Literal["vendor/widgets/list"] = "vendor/widgets/list"
class _OptionalParamsWidgetRequest(Request[dict[str, Any] | None, Literal["vendor/widgets/get"]]):
"""Optional params, so a send can carry no params key at all."""
method: Literal["vendor/widgets/get"] = "vendor/widgets/get"
params: dict[str, Any] | None = None
name_param = "widgetId"
def _adopt_modern(session: ClientSession) -> None:
session.adopt(
types.DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="stub", version="0"),
)
)
def _adopt_handshake(session: ClientSession) -> None:
session.adopt(
types.InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="stub", version="0"),
)
)
def _headers(opts: CallOptions) -> dict[str, str]:
return opts.get("headers") or {}
@pytest.mark.anyio
async def test_vendor_name_param_emits_mcp_name_on_the_modern_path() -> None:
"""A vendor `name_param` emits `Mcp-Name` on a modern wire even outside `NAME_BEARING_METHODS`."""
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_modern(session)
await session.send_request(_GetWidgetRequest(params=_GetWidgetParams(widget_id="w-1")), types.EmptyResult)
[(_, opts)] = dispatcher.calls
assert _headers(opts)[MCP_NAME_HEADER] == "w-1"
@pytest.mark.anyio
async def test_vendor_name_param_emits_mcp_name_on_the_handshake_path() -> None:
"""The handshake stamp sets no `Mcp-Name`, so on a legacy wire the delta is the emitter."""
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_handshake(session)
await session.send_request(_GetWidgetRequest(params=_GetWidgetParams(widget_id="w-1")), types.EmptyResult)
[(_, opts)] = dispatcher.calls
assert _headers(opts)[MCP_NAME_HEADER] == "w-1"
# The stamp's own headers survive the delta.
assert _headers(opts)[MCP_PROTOCOL_VERSION_HEADER] == LATEST_HANDSHAKE_VERSION
@pytest.mark.anyio
async def test_name_value_passes_through_encode_header_value() -> None:
"""A non-ASCII name is base64-sentinel encoded, a spec MUST for `Mcp-Name`."""
name = "wídget ✨"
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_handshake(session)
await session.send_request(_GetWidgetRequest(params=_GetWidgetParams(widget_id=name)), types.EmptyResult)
[(_, opts)] = dispatcher.calls
assert _headers(opts)[MCP_NAME_HEADER] == encode_header_value(name)
assert _headers(opts)[MCP_NAME_HEADER].startswith("=?base64?")
@pytest.mark.anyio
async def test_core_tools_call_header_comes_from_the_stamp_alone() -> None:
"""Core `tools/call` is unchanged: the modern stamp emits the header; legacy stays headerless."""
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_modern(session)
await session.call_tool("my-tool", {})
_adopt_handshake(session)
await session.call_tool("my-tool", {})
(_, modern_opts), (_, legacy_opts) = (call for call in dispatcher.calls if call[0] == "tools/call")
assert _headers(modern_opts)[MCP_NAME_HEADER] == "my-tool"
assert MCP_NAME_HEADER not in _headers(legacy_opts)
@pytest.mark.anyio
async def test_stamp_table_rows_win_over_name_param_by_ordering() -> None:
"""A stamp-emitted `Mcp-Name` wins; `name_param` never overwrites an existing header."""
dispatcher = _RecordingDispatcher()
request = _ShadowCallToolRequest(params={"name": "real-tool", "customKey": "other-value"})
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_modern(session)
await session.send_request(request, types.CallToolResult)
[(_, opts)] = dispatcher.calls
assert _headers(opts)[MCP_NAME_HEADER] == "real-tool"
@pytest.mark.anyio
async def test_vendor_name_param_emits_mcp_name_on_the_preconnect_path() -> None:
"""Emission is era-unconditional: a session that never adopts still emits `Mcp-Name`."""
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
await session.send_request(_GetWidgetRequest(params=_GetWidgetParams(widget_id="w-1")), types.EmptyResult)
[(_, opts)] = dispatcher.calls
assert _headers(opts) == {MCP_NAME_HEADER: "w-1"} # and no era headers: nothing adopted
@pytest.mark.anyio
async def test_missing_name_value_fails_loud_naming_method_and_key() -> None:
"""A missing name value raises ValueError naming the method and key, before the wire."""
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_handshake(session)
with pytest.raises(ValueError) as exc_info:
await session.send_request(_RawWidgetRequest(params={}), types.EmptyResult)
assert dispatcher.calls == [] # raised before reaching the wire
assert str(exc_info.value) == snapshot("vendor/widgets/get requires params['widgetId'] for Mcp-Name")
@pytest.mark.anyio
async def test_non_string_name_value_fails_loud() -> None:
"""A non-string name value raises the same ValueError as a missing one."""
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_handshake(session)
with pytest.raises(ValueError) as exc_info:
await session.send_request(_RawWidgetRequest(params={"widgetId": 7}), types.EmptyResult)
assert dispatcher.calls == []
assert str(exc_info.value) == snapshot("vendor/widgets/get requires params['widgetId'] for Mcp-Name")
@pytest.mark.anyio
async def test_absent_params_fails_loud_not_attribute_error() -> None:
"""Absent params still raise the documented ValueError, not an AttributeError."""
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_handshake(session)
with pytest.raises(ValueError) as exc_info:
await session.send_request(_OptionalParamsWidgetRequest(), types.EmptyResult)
assert dispatcher.calls == []
assert str(exc_info.value) == snapshot("vendor/widgets/get requires params['widgetId'] for Mcp-Name")
@pytest.mark.anyio
async def test_request_without_name_param_sends_no_mcp_name() -> None:
"""No `name_param` and a method outside the core table emits no `Mcp-Name` on either era."""
dispatcher = _RecordingDispatcher()
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
_adopt_modern(session)
await session.send_request(_PlainVendorRequest(params={}), types.EmptyResult)
_adopt_handshake(session)
await session.send_ping()
for _, opts in dispatcher.calls:
assert MCP_NAME_HEADER not in _headers(opts)
+468
View File
@@ -0,0 +1,468 @@
"""`ClientSession` result claims: construction validation, activation at modern
adopts only, claimed-result routing, the version-aware capability ad, and the
`allow_claimed` escape hatch."""
from collections.abc import Mapping
from typing import Any, Literal
import anyio
import anyio.abc
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
CallToolResult,
Implementation,
InputRequiredResult,
ListToolsResult,
Result,
ServerCapabilities,
TextContent,
Tool,
)
from mcp_types.methods import validate_server_result
from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
from pydantic import ValidationError
from typing_extensions import assert_type
from mcp.client.extension import ClaimContext, ResultClaim, UnexpectedClaimedResult
from mcp.client.session import ClientSession, _CallToolResultAdapter
from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest
_TASKS_EXT = "com.example/tasks"
_AD_ONLY_EXT = "com.example/flags"
class _TaskResult(Result):
"""A claimed result shape, tagged `task`."""
result_type: Literal["task"] = "task"
task_id: str
async def _resolve_task(result: _TaskResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError # session-tier tests never drive a resolver; that is the Client's job
def _task_claim(**kwargs: Any) -> ResultClaim[_TaskResult]:
return ResultClaim(result_type="task", model=_TaskResult, resolve=_resolve_task, **kwargs)
_COMPLETE_TOOL_RESULT = CallToolResult(content=[TextContent(type="text", text="ok")]).model_dump(
by_alias=True, mode="json", exclude_none=True
)
_CLAIMED_TASK_RESULT = {"resultType": "task", "taskId": "t-1"}
_TOOL_LISTING = ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})]).model_dump(
by_alias=True, mode="json", exclude_none=True
)
_INITIALIZE_RESULT = types.InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="stub", version="0"),
).model_dump(by_alias=True, mode="json", exclude_none=True)
class _RecordingDispatcher:
"""Records every send and answers each method with a canned result."""
def __init__(self, tool_result: dict[str, Any] | None = None) -> None:
self.calls: list[tuple[str, Mapping[str, Any] | None, CallOptions]] = []
self.notifications: list[str] = []
self._tool_result = tool_result if tool_result is not None else _COMPLETE_TOOL_RESULT
async def run(
self,
on_request: OnRequest,
on_notify: OnNotify,
*,
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
) -> None:
task_status.started()
await anyio.sleep_forever()
async def send_raw_request(
self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None
) -> dict[str, Any]:
self.calls.append((method, params, opts or {}))
if method == "tools/call":
return self._tool_result
if method == "tools/list":
return _TOOL_LISTING
if method == "initialize":
return _INITIALIZE_RESULT
return {}
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
self.notifications.append(method)
def _claims_session(dispatcher: _RecordingDispatcher, *claims: ResultClaim[Any]) -> ClientSession:
return ClientSession(dispatcher=dispatcher, extensions={_TASKS_EXT: {}}, result_claims={_TASKS_EXT: list(claims)})
def _adopt_modern(session: ClientSession) -> None:
session.adopt(
types.DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="stub", version="0"),
)
)
def _adopt_handshake(session: ClientSession) -> None:
session.adopt(
types.InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="stub", version="0"),
)
)
def test_duplicate_claim_tag_across_extensions_rejected() -> None:
"""SDK-defined: two claims on the same resultType cannot be routed apart, so construction fails."""
with pytest.raises(ValueError) as exc_info:
ClientSession(
dispatcher=_RecordingDispatcher(),
extensions={_TASKS_EXT: {}, _AD_ONLY_EXT: {}},
result_claims={_TASKS_EXT: [_task_claim()], _AD_ONLY_EXT: [_task_claim()]},
)
assert str(exc_info.value) == snapshot("duplicate result claim for resultType 'task'")
def test_claims_keyed_to_unadvertised_extension_rejected() -> None:
"""SDK-defined: a `result_claims` key with no `extensions` entry advertises nothing, so construction fails."""
messages: list[str] = []
for extensions in (None, {_AD_ONLY_EXT: {"flag": True}}):
with pytest.raises(ValueError) as exc_info:
ClientSession(
dispatcher=_RecordingDispatcher(),
extensions=extensions,
result_claims={_TASKS_EXT: [_task_claim()]},
)
messages.append(str(exc_info.value))
assert messages == snapshot(
[
"result_claims key 'com.example/tasks' has no extensions entry; a claim is only "
"advertised through its extension's capability ad",
"result_claims key 'com.example/tasks' has no extensions entry; a claim is only "
"advertised through its extension's capability ad",
]
)
def test_empty_claim_sequence_rejected() -> None:
"""SDK-defined: an empty claim list is rejected at construction; a claim-less extension omits the key."""
with pytest.raises(ValueError) as exc_info:
ClientSession(dispatcher=_RecordingDispatcher(), extensions={_TASKS_EXT: {}}, result_claims={_TASKS_EXT: []})
assert str(exc_info.value) == snapshot(
"result_claims['com.example/tasks'] is empty and would drop the extension from "
"the capability ad at every version. Omit the key instead"
)
def test_empty_settings_count_as_an_advertised_extension() -> None:
"""SDK-defined: empty settings ({}) still count as an ad, so claims keyed to the extension construct."""
session = _claims_session(_RecordingDispatcher(), _task_claim())
assert isinstance(session, ClientSession)
def test_without_claims_the_call_tool_adapter_is_the_module_constant() -> None:
"""SDK-defined: with zero active claims the session holds the module-level adapter by identity."""
session = ClientSession(dispatcher=_RecordingDispatcher())
assert session._call_tool_adapter is _CallToolResultAdapter
_adopt_modern(session)
assert session._call_tool_adapter is _CallToolResultAdapter
_adopt_handshake(session)
assert session._call_tool_adapter is _CallToolResultAdapter
@pytest.mark.anyio
@pytest.mark.parametrize("protocol_versions", [None, frozenset({LATEST_MODERN_VERSION})])
async def test_modern_adopt_activates_claims_and_routes_claimed_results(
protocol_versions: frozenset[str] | None,
) -> None:
"""SDK-defined: at a modern adopt, a claim active at the negotiated version routes
the claimed raw to the claim model."""
dispatcher = _RecordingDispatcher(tool_result=_CLAIMED_TASK_RESULT)
session = _claims_session(dispatcher, _task_claim(protocol_versions=protocol_versions))
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
result = await session.call_tool("t", {}, allow_claimed=True)
assert isinstance(result, _TaskResult)
assert result.task_id == "t-1"
@pytest.mark.anyio
async def test_legacy_adopt_clears_active_claims() -> None:
"""SDK-defined: a legacy adopt clears active claims and restores the module-level adapter."""
dispatcher = _RecordingDispatcher(tool_result=_CLAIMED_TASK_RESULT)
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
assert isinstance(await session.call_tool("t", {}, allow_claimed=True), _TaskResult)
_adopt_handshake(session)
assert session._call_tool_adapter is _CallToolResultAdapter
with pytest.raises(ValidationError):
await session.call_tool("t", {}, allow_claimed=True)
# Rejected at response parsing; the request did reach the wire.
assert dispatcher.calls[-1][0] == "tools/call"
@pytest.mark.anyio
async def test_modern_readopt_after_legacy_reactivates_claims() -> None:
"""SDK-defined: a modern re-adopt after legacy reactivates the claims."""
dispatcher = _RecordingDispatcher(tool_result=_CLAIMED_TASK_RESULT)
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
_adopt_handshake(session)
assert session._call_tool_adapter is _CallToolResultAdapter
_adopt_modern(session)
result = await session.call_tool("t", {}, allow_claimed=True)
assert isinstance(result, _TaskResult)
assert session._call_tool_adapter is not _CallToolResultAdapter
@pytest.mark.anyio
async def test_legacy_initialize_ad_drops_claim_bearing_identifiers() -> None:
"""SDK-defined: the legacy initialize ad drops claim-bearing identifiers; ad-only ones ride along."""
dispatcher = _RecordingDispatcher()
session = ClientSession(
dispatcher=dispatcher,
extensions={_TASKS_EXT: {}, _AD_ONLY_EXT: {"flag": True}},
result_claims={_TASKS_EXT: [_task_claim()]},
)
with anyio.fail_after(5):
async with session:
await session.initialize()
[(_, params, _)] = [call for call in dispatcher.calls if call[0] == "initialize"]
assert params is not None
assert params["capabilities"]["extensions"] == {_AD_ONLY_EXT: {"flag": True}}
@pytest.mark.anyio
async def test_legacy_ad_omits_extensions_entirely_when_every_identifier_drops() -> None:
"""SDK-defined: when every identifier drops, the ad omits the `extensions` key entirely."""
dispatcher = _RecordingDispatcher()
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
await session.initialize()
[(_, params, _)] = [call for call in dispatcher.calls if call[0] == "initialize"]
assert params is not None
assert "extensions" not in params["capabilities"]
@pytest.mark.anyio
async def test_modern_adopt_ad_includes_active_claim_identifiers() -> None:
"""SDK-defined: the modern per-request `_meta` ad includes identifiers whose claims are active."""
dispatcher = _RecordingDispatcher()
session = ClientSession(
dispatcher=dispatcher,
extensions={_TASKS_EXT: {}, _AD_ONLY_EXT: {"flag": True}},
result_claims={_TASKS_EXT: [_task_claim()]},
)
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
await session.send_ping()
[(_, params, _)] = dispatcher.calls
assert params is not None
capabilities = params["_meta"][CLIENT_CAPABILITIES_META_KEY]
assert capabilities["extensions"] == {_TASKS_EXT: {}, _AD_ONLY_EXT: {"flag": True}}
@pytest.mark.anyio
async def test_discover_probe_ad_includes_claim_identifiers_at_the_probe_version() -> None:
"""SDK-defined: `send_discover` builds its `_meta` ad at the probe version, where claims are active."""
dispatcher = _RecordingDispatcher()
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
await session.send_discover(LATEST_MODERN_VERSION)
[(_, params, _)] = dispatcher.calls
assert params is not None
capabilities = params["_meta"][CLIENT_CAPABILITIES_META_KEY]
assert capabilities["extensions"] == {_TASKS_EXT: {}}
@pytest.mark.anyio
async def test_discover_probe_ad_drops_claim_identifiers_at_a_legacy_probe_version() -> None:
"""SDK-defined: at a legacy probe version no claim can be active, so the identifier drops."""
dispatcher = _RecordingDispatcher()
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
await session.send_discover(LATEST_HANDSHAKE_VERSION)
[(_, params, _)] = dispatcher.calls
assert params is not None
capabilities = params["_meta"][CLIENT_CAPABILITIES_META_KEY]
assert "extensions" not in capabilities
class _CoreTaggedResult(Result):
"""A claim whose wire tag collides with the adapter's internal routing sentinel."""
result_type: Literal["core"] = "core"
payload: str = ""
async def _resolve_core_tagged(result: _CoreTaggedResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError
@pytest.mark.anyio
async def test_claim_tagged_core_cannot_hijack_core_parsing() -> None:
"""SDK-defined: a claim may use "core" as its wire tag without colliding with core parsing."""
claim = ResultClaim(result_type="core", model=_CoreTaggedResult, resolve=_resolve_core_tagged)
dispatcher = _RecordingDispatcher(tool_result={"resultType": "core", "payload": "p-1"})
session = ClientSession(dispatcher=dispatcher, extensions={_TASKS_EXT: {}}, result_claims={_TASKS_EXT: [claim]})
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
ordinary = session._call_tool_adapter.validate_python(_COMPLETE_TOOL_RESULT)
claimed = await session.call_tool("t", {}, allow_claimed=True)
assert isinstance(ordinary, CallToolResult)
assert isinstance(claimed, _CoreTaggedResult)
@pytest.mark.anyio
@pytest.mark.parametrize("with_claims", [True, False])
async def test_unknown_result_type_fails_validation_with_and_without_claims(with_claims: bool) -> None:
"""SDK-defined: a resultType outside the active claim set fails core validation, claims or not."""
raw = {"resultType": "weird", "taskId": "t-1"}
dispatcher = _RecordingDispatcher(tool_result=raw)
session = _claims_session(dispatcher, _task_claim()) if with_claims else ClientSession(dispatcher=dispatcher)
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
with pytest.raises(ValidationError):
await session.call_tool("t", {}, allow_claimed=True)
# Rejected at response parsing; the request did reach the wire.
assert dispatcher.calls[-1][0] == "tools/call"
@pytest.mark.anyio
async def test_non_string_result_type_fails_core_validation_not_discrimination() -> None:
"""SDK-defined: a non-string resultType stays on the core arm and fails as ValidationError, not TypeError."""
raw: dict[str, Any] = {"resultType": {"nested": True}}
dispatcher = _RecordingDispatcher(tool_result=raw)
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
with pytest.raises(ValidationError):
await session.call_tool("t", {}, allow_claimed=True)
# Rejected at response parsing; the request did reach the wire.
assert dispatcher.calls[-1][0] == "tools/call"
def test_adopt_built_adapter_revalidates_model_instances() -> None:
"""SDK-defined: the adopt-built adapter routes already-built model instances as well as raw dicts."""
session = _claims_session(_RecordingDispatcher(), _task_claim())
_adopt_modern(session)
adapter = session._call_tool_adapter
claimed = adapter.validate_python(_TaskResult(task_id="t-2"))
assert isinstance(claimed, _TaskResult)
core = adapter.validate_python(CallToolResult(content=[]))
assert isinstance(core, CallToolResult)
@pytest.mark.anyio
async def test_input_required_routes_to_core_arm_with_claims_active() -> None:
"""Spec-mandated: `input_required` is core vocabulary; active claims leave that arm untouched."""
raw = {"resultType": "input_required", "requestState": "s-1"}
session = _claims_session(_RecordingDispatcher(tool_result=raw), _task_claim())
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
result = await session.call_tool("t", {}, allow_input_required=True, allow_claimed=True)
assert isinstance(result, InputRequiredResult)
assert result.request_state == "s-1"
@pytest.mark.anyio
async def test_claimed_result_raises_unexpected_claimed_result_by_default() -> None:
"""SDK-defined: without `allow_claimed` a claimed shape raises, carrying the parsed
result so the caller can clean up any server-side state it references."""
dispatcher = _RecordingDispatcher(tool_result=_CLAIMED_TASK_RESULT)
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
with pytest.raises(UnexpectedClaimedResult) as exc_info:
await session.call_tool("t", {})
# The shape parsed and then raised; the request did reach the wire.
assert dispatcher.calls[-1][0] == "tools/call"
assert isinstance(exc_info.value.result, _TaskResult)
assert exc_info.value.result.task_id == "t-1"
assert str(exc_info.value) == snapshot(
"Server returned a claimed result (_TaskResult); pass the owning extension to "
"Client(extensions=[...]) for transparent resolution, or call with allow_claimed=True "
"and handle the shape. The carried result may reference server-side state needing cleanup."
)
@pytest.mark.anyio
async def test_call_tool_result_path_identical_under_both_allow_claimed_values() -> None:
"""SDK-defined: `allow_claimed` only affects claimed shapes; ordinary results come back identical."""
dispatcher = _RecordingDispatcher()
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
r_default = await session.call_tool("t", {})
r_opted = await session.call_tool("t", {}, allow_claimed=True)
assert isinstance(r_opted, CallToolResult)
assert r_opted == r_default
@pytest.mark.anyio
async def test_call_tool_overload_matrix_narrows_statically() -> None:
"""SDK-defined: each flag combination narrows `call_tool` to its documented return union under pyright."""
dispatcher = _RecordingDispatcher()
session = _claims_session(dispatcher, _task_claim())
with anyio.fail_after(5):
async with session:
_adopt_modern(session)
r1 = await session.call_tool("t", {})
assert_type(r1, CallToolResult)
r2 = await session.call_tool("t", {}, allow_input_required=True)
assert_type(r2, CallToolResult | InputRequiredResult)
r3 = await session.call_tool("t", {}, allow_claimed=True)
assert_type(r3, CallToolResult | Result)
r4 = await session.call_tool("t", {}, allow_input_required=True, allow_claimed=True)
assert_type(r4, CallToolResult | InputRequiredResult | Result)
assert [type(r) for r in (r1, r2, r3, r4)] == [CallToolResult] * 4
def test_claimed_raw_passes_v2026_tools_call_surface_validation() -> None:
"""Pins the claim path's dependency: an unknown resultType passes `validate_server_result`
at 2026-07-28; this failing is the signal that mcp-types tightened the surface."""
validate_server_result("tools/call", LATEST_MODERN_VERSION, {"resultType": "task", "taskId": "t-1"})
@@ -0,0 +1,287 @@
"""`ClientSession` notification bindings: serialized per-binding delivery through a
bounded FIFO, consulted only for methods the negotiated version's core tables do
not know."""
import logging
import anyio
import mcp_types as types
import pytest
from mcp_types import EmptyResult, Implementation, ServerCapabilities
from mcp_types.version import LATEST_MODERN_VERSION
from pydantic import BaseModel
from mcp.client.extension import NotificationBinding
from mcp.client.session import _NOTIFICATION_QUEUE_SIZE, ClientSession
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
from mcp.shared.dispatcher import DispatchContext
from mcp.shared.transport_context import TransportContext
_VENDOR_METHOD = "notifications/vendor/task_done"
class _EventParams(BaseModel):
seq: int
async def _server_on_request(
ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None
) -> dict[str, object]:
assert method == "ping"
return {}
async def _server_on_notify(
ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None
) -> None:
raise NotImplementedError
def _adopt_modern(session: ClientSession) -> None:
session.adopt(
types.DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="stub", version="0"),
)
)
async def _noop_handler(params: _EventParams) -> None:
raise NotImplementedError # construction-only tests never deliver
def test_duplicate_binding_method_rejected() -> None:
"""SDK-defined: two bindings on one wire method cannot be routed apart, so construction fails."""
client_side, _ = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=_noop_handler)
with pytest.raises(ValueError) as exc_info:
ClientSession(dispatcher=client_side, notification_bindings=[binding, binding])
assert str(exc_info.value) == "duplicate notification binding for method 'notifications/vendor/task_done'"
@pytest.mark.anyio
async def test_bound_vendor_notifications_are_delivered_in_order() -> None:
"""SDK-defined: one consumer per binding delivers events in the order the server sent them."""
delivered: list[int] = []
done = anyio.Event()
async def on_event(params: _EventParams) -> None:
delivered.append(params.seq)
if params.seq == 3:
done.set()
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
for seq in (1, 2, 3):
await server_side.notify(_VENDOR_METHOD, {"seq": seq})
await done.wait()
server_side.close()
assert delivered == [1, 2, 3]
@pytest.mark.anyio
async def test_binding_handler_may_do_session_io_without_deadlock() -> None:
"""SDK-defined: delivery is spawn-decoupled, so a handler may await session I/O without deadlock."""
pongs: list[EmptyResult] = []
done = anyio.Event()
client_side, server_side = create_direct_dispatcher_pair()
async def on_event(params: _EventParams) -> None:
pongs.append(await session.send_ping())
done.set()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
await server_side.notify(_VENDOR_METHOD, {"seq": 1})
await done.wait()
server_side.close()
assert pongs == [EmptyResult()]
@pytest.mark.anyio
async def test_overflow_drops_oldest_event_with_a_warning(caplog: pytest.LogCaptureFixture) -> None:
"""SDK-defined: on overflow the bounded FIFO drops the oldest queued event with a
warning; everything still queued delivers in order."""
delivered: list[int] = []
consumer_blocked = anyio.Event()
gate = anyio.Event()
done = anyio.Event()
last_seq = _NOTIFICATION_QUEUE_SIZE + 1
async def on_event(params: _EventParams) -> None:
delivered.append(params.seq)
if params.seq == 0:
consumer_blocked.set()
await gate.wait()
if params.seq == last_seq:
done.set()
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
await server_side.notify(_VENDOR_METHOD, {"seq": 0})
await consumer_blocked.wait()
for seq in range(1, last_seq + 1):
await server_side.notify(_VENDOR_METHOD, {"seq": seq})
gate.set()
await done.wait()
server_side.close()
assert delivered == [0, *range(2, last_seq + 1)]
assert caplog.text.count(f"notification queue for {_VENDOR_METHOD!r} is full") == 1
@pytest.mark.anyio
async def test_invalid_params_are_warned_and_dropped_without_reaching_handler(
caplog: pytest.LogCaptureFixture,
) -> None:
"""SDK-defined: params failing the binding's model are warned and dropped; later valid events deliver."""
delivered: list[int] = []
done = anyio.Event()
async def on_event(params: _EventParams) -> None:
delivered.append(params.seq)
done.set()
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
await server_side.notify(_VENDOR_METHOD, {"bogus": "no seq"})
await server_side.notify(_VENDOR_METHOD, {"seq": 1})
await done.wait()
server_side.close()
assert delivered == [1]
assert f"Failed to validate notification: {_VENDOR_METHOD}" in caplog.text
@pytest.mark.anyio
async def test_unbound_vendor_notification_keeps_the_debug_drop(caplog: pytest.LogCaptureFixture) -> None:
"""SDK-defined: a vendor method with no binding keeps the debug-log-and-drop behaviour."""
caplog.set_level(logging.DEBUG, logger="client")
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=_noop_handler)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
await server_side.notify("notifications/vendor/unbound", {"seq": 1})
server_side.close()
assert f"dropped 'notifications/vendor/unbound': not defined at {LATEST_MODERN_VERSION}" in caplog.text
@pytest.mark.anyio
async def test_core_known_method_never_reaches_binding_and_warns_once_at_adopt(
caplog: pytest.LogCaptureFixture,
) -> None:
"""SDK-defined: a binding for a core-known method never fires and warns once at
adopt(); the typed callback still runs."""
logged: list[types.LoggingMessageNotificationParams] = []
async def logging_callback(params: types.LoggingMessageNotificationParams) -> None:
logged.append(params)
async def on_message(params: BaseModel) -> None:
raise NotImplementedError # structurally unreachable: core parses the method first
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method="notifications/message", params_type=BaseModel, handler=on_message)
session = ClientSession(dispatcher=client_side, logging_callback=logging_callback, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
# In-process notify() awaits _on_notify inline, so the typed callback has already run.
await server_side.notify("notifications/message", {"level": "info", "data": "hello"})
server_side.close()
assert [params.data for params in logged] == ["hello"]
# The bound handler never ran; a delivery would have logged its NotImplementedError.
assert "notification binding handler" not in caplog.text
expected = f"notification binding for 'notifications/message' will never fire at {LATEST_MODERN_VERSION}"
assert caplog.text.count(expected) == 1
@pytest.mark.anyio
async def test_handler_exception_is_contained_and_later_events_deliver(caplog: pytest.LogCaptureFixture) -> None:
"""SDK-defined: a raising handler costs only that delivery; later events still deliver."""
delivered: list[int] = []
done = anyio.Event()
async def on_event(params: _EventParams) -> None:
if params.seq == 1:
raise ValueError("handler boom")
delivered.append(params.seq)
done.set()
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
await server_side.notify(_VENDOR_METHOD, {"seq": 1})
await server_side.notify(_VENDOR_METHOD, {"seq": 2})
await done.wait()
server_side.close()
assert delivered == [2]
assert f"notification binding handler for {_VENDOR_METHOD!r} raised" in caplog.text
@pytest.mark.anyio
async def test_binding_delivery_works_without_adopt() -> None:
"""SDK-defined: bindings deliver pre-handshake, under the default version tables."""
delivered: list[int] = []
done = anyio.Event()
async def on_event(params: _EventParams) -> None:
delivered.append(params.seq)
done.set()
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
await server_side.notify(_VENDOR_METHOD, {"seq": 7})
await done.wait()
server_side.close()
assert delivered == [7]
+66
View File
@@ -0,0 +1,66 @@
"""`dispatch_input_request` and `validate_tool_result` are public `ClientSession` API."""
import mcp_types as types
import pytest
from mcp_types import (
CallToolResult,
ErrorData,
ListRootsResult,
ListToolsResult,
PaginatedRequestParams,
Tool,
)
from mcp.client.client import Client
from mcp.client.session import ClientRequestContext, ClientSession
from mcp.server import Server, ServerRequestContext
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
@pytest.mark.anyio
async def test_dispatch_input_request_routes_through_the_callback_table() -> None:
expected = ListRootsResult(roots=[])
async def list_roots(context: ClientRequestContext) -> ListRootsResult:
return expected
client_side, _server_side = create_direct_dispatcher_pair()
session = ClientSession(dispatcher=client_side, list_roots_callback=list_roots)
ctx = ClientRequestContext(session=session, request_id="r-1")
response = await session.dispatch_input_request(ctx, types.ListRootsRequest())
assert response is expected
@pytest.mark.anyio
async def test_dispatch_input_request_returns_error_data_on_refusal() -> None:
"""With no callback registered, refusal comes back as `ErrorData`, not a raise."""
client_side, _server_side = create_direct_dispatcher_pair()
session = ClientSession(dispatcher=client_side)
ctx = ClientRequestContext(session=session, request_id="r-1")
response = await session.dispatch_input_request(ctx, types.ListRootsRequest())
assert isinstance(response, ErrorData)
assert response.code == types.INVALID_REQUEST
def _make_server(output_schema: dict[str, object]) -> Server:
async def on_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"}, output_schema=output_schema)])
return Server("test-server", on_list_tools=on_list_tools)
@pytest.mark.anyio
async def test_validate_tool_result_passes_a_conforming_result() -> None:
server = _make_server({"type": "object", "properties": {"x": {"type": "integer"}}, "required": ["x"]})
async with Client(server) as client:
# The session fetches the listing itself when the tool isn't cached yet.
await client.session.validate_tool_result("t", CallToolResult(content=[], structured_content={"x": 1}))
@pytest.mark.anyio
async def test_validate_tool_result_raises_on_schema_mismatch() -> None:
server = _make_server({"type": "object", "properties": {"x": {"type": "integer"}}, "required": ["x"]})
async with Client(server) as client:
# Stable SDK prefix only: the message tail is jsonschema text that shifts with the dependency.
with pytest.raises(RuntimeError, match="Invalid structured content returned by tool t"):
await client.session.validate_tool_result("t", CallToolResult(content=[], structured_content={"x": "no"}))
+4 -1
View File
@@ -7,6 +7,7 @@ from mcp_types import TextContent, TextResourceContents
from docs_src.apps import tutorial001, tutorial002, tutorial003
from mcp import Client
from mcp.client import advertise
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID
# See test_index.py for why this is a per-module mark and not a conftest hook.
@@ -34,7 +35,9 @@ async def test_the_ui_resource_is_served_as_the_app_mime_type() -> None:
async def test_one_tool_two_answers() -> None:
"""tutorial001: the canonical degradation pattern: raw data for a client that
negotiated Apps, a human sentence for one that did not."""
async with Client(tutorial001.mcp, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as ui_client:
async with Client(
tutorial001.mcp, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]
) as ui_client:
rich = await ui_client.call_tool("get_time", {})
async with Client(tutorial001.mcp) as text_client:
plain = await text_client.call_tool("get_time", {})
+41 -6
View File
@@ -1,15 +1,22 @@
"""`docs/advanced/extensions.md`: every claim the page makes, proved against the real SDK."""
import logging
from typing import cast
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import METHOD_NOT_FOUND, MISSING_REQUIRED_CLIENT_CAPABILITY, TextContent
from docs_src.extensions import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005
from docs_src.extensions import (
tutorial001,
tutorial002,
tutorial003,
tutorial004,
tutorial005,
tutorial006,
tutorial007,
)
from mcp import Client, MCPError
from mcp.client import advertise
from mcp.server.extension import Extension
# See test_index.py for why this is a per-module mark and not a conftest hook.
@@ -70,7 +77,7 @@ async def test_vendor_method_rejects_a_non_declaring_client_with_32021() -> None
async with Client(tutorial004.mcp) as client:
request = tutorial004.SearchRequest(params=tutorial004.SearchParams(query="mcp"))
with pytest.raises(MCPError) as exc_info:
await client.session.send_request(cast("types.ClientRequest", request), tutorial004.SearchResult)
await client.session.send_request(request, tutorial004.SearchResult)
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
assert exc_info.value.error.data == {"requiredCapabilities": {"extensions": {"com.example/search": {}}}}
@@ -78,10 +85,10 @@ async def test_vendor_method_rejects_a_non_declaring_client_with_32021() -> None
async def test_version_pinned_method_is_not_found_on_a_legacy_connection() -> None:
"""tutorial004: `protocol_versions={"2026-07-28"}` makes the method METHOD_NOT_FOUND
at any other wire version; for a legacy client it doesn't exist."""
async with Client(tutorial004.mcp, mode="legacy", extensions={tutorial004.EXTENSION_ID: {}}) as client:
async with Client(tutorial004.mcp, mode="legacy", extensions=[advertise(tutorial004.EXTENSION_ID)]) as client:
request = tutorial004.SearchRequest(params=tutorial004.SearchParams(query="mcp"))
with pytest.raises(MCPError) as exc_info:
await client.session.send_request(cast("types.ClientRequest", request), tutorial004.SearchResult)
await client.session.send_request(request, tutorial004.SearchResult)
assert exc_info.value.code == METHOD_NOT_FOUND
@@ -95,3 +102,31 @@ async def test_interceptor_observes_the_call_and_passes_the_result_through(
assert result.structured_content == {"result": 5}
messages = [record.getMessage() for record in caplog.records if record.name == tutorial005.logger.name]
assert messages == ["tool 'add' called"]
async def test_the_receipts_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None:
"""tutorial006: `main()` runs as printed and the output is the redeemed result, never the claimed shape."""
await tutorial006.main()
assert "goods for r-117" in capsys.readouterr().out
async def test_a_client_without_the_extension_is_refused_by_the_gate() -> None:
"""The page's off-by-default claim: the server's capability gate refuses a non-declaring client."""
async with Client(tutorial006.mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("buy", {"item": "lamp"})
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
async def test_session_tier_allow_claimed_returns_the_raw_shape() -> None:
"""The page's escape hatch: `allow_claimed=True` returns the parsed claim model, not the resolved result."""
async with Client(tutorial006.mcp, extensions=[tutorial006.Receipts()]) as client:
result = await client.session.call_tool("buy", {"item": "lamp"}, allow_claimed=True)
assert isinstance(result, tutorial006.ReceiptResult)
assert result.receipt_token == "r-117"
async def test_the_jobs_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None:
"""tutorial007: a vendor request with `name_param` round-trips `send_request` with no registration."""
await tutorial007.main()
assert "job-7 is running" in capsys.readouterr().out
+9 -1
View File
@@ -7,7 +7,7 @@ server's real Starlette app through the in-process streaming bridge, so the full
(session ids, SSE encoding, session management) runs with no sockets, threads, or subprocesses.
"""
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from functools import partial
from typing import Any, Protocol
@@ -30,6 +30,7 @@ from starlette.responses import Response
from starlette.routing import Mount, Route
from mcp.client.client import Client
from mcp.client.extension import ClientExtension
from mcp.client.session import ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamable_http_client
@@ -70,6 +71,7 @@ class Connect(Protocol):
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
extensions: Sequence[ClientExtension] | None = None,
spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> AbstractAsyncContextManager[Client]: ...
@@ -85,6 +87,7 @@ async def connect_in_memory(
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
extensions: Sequence[ClientExtension] | None = None,
spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> AsyncIterator[Client]:
"""Yield a Client connected to the server over the in-memory transport.
@@ -103,6 +106,7 @@ async def connect_in_memory(
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,
extensions=extensions,
) as client:
yield client
@@ -122,6 +126,7 @@ async def connect_over_streamable_http(
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
extensions: Sequence[ClientExtension] | None = None,
spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> AsyncIterator[Client]:
"""Yield a Client connected to the server's streamable HTTP app, entirely in process.
@@ -156,6 +161,7 @@ async def connect_over_streamable_http(
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,
extensions=extensions,
) as client,
):
yield client
@@ -357,6 +363,7 @@ async def connect_over_sse(
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
extensions: Sequence[ClientExtension] | None = None,
spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> AsyncIterator[Client]:
"""Yield a Client connected to the server's legacy SSE transport, entirely in process."""
@@ -390,5 +397,6 @@ async def connect_over_sse(
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,
extensions=extensions,
) as client:
yield client
+76
View File
@@ -2384,6 +2384,69 @@ REQUIREMENTS: dict[str, Requirement] = {
),
),
# ═══════════════════════════════════════════════════════════════════════════
# Extensions (SEP-2133): client-side result claims and the capability ad
# ═══════════════════════════════════════════════════════════════════════════
"extensions:client:claimed-result-resolved": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic#resulttype",
behavior=(
"A tools/call answered with an extension-claimed resultType is finished by the owning "
"ClientExtension's claim resolver, and Client.call_tool returns the resolver's ordinary "
"CallToolResult. The resolver may send follow-up requests through the session it is handed."
),
added_in="2026-07-28",
),
"extensions:client:claimed-result-undeclared-invalid": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic#resulttype",
behavior=(
"A resultType unrecognized by the client is invalid: a claimed shape delivered to a client that "
"did not construct the owning extension fails result validation (the supported set is core plus "
"declared claims, never more)."
),
added_in="2026-07-28",
note=(
"Known leniency: the monolith result surface still accepts an unknown tag when the payload "
"also parses as a complete core result (open result_type, extras ignored). Rejecting tags "
"outside core plus active claims is a tracked follow-up ruling."
),
),
"extensions:client:capability-ad:gates-server-behaviour": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic#resulttype",
behavior=(
"The per-request _meta capability ad carries each declared extension's identifier and settings, "
"and is what entitles the server to substitute that extension's claimed shapes: a server "
"extension gating on the ad sees the declared settings, and refuses a non-declaring client with "
"-32021 (missing required client capability)."
),
added_in="2026-07-28",
),
"extensions:client:capability-ad:legacy-omits-claimed": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic#resulttype",
behavior=(
"On a legacy connection no claim can activate, and the initialize capability ad omits "
"claim-bearing identifiers in the same breath (claim-less identifiers still advertise), so the "
"client never advertises an extension whose claimed shapes it would reject."
),
removed_in="2026-07-28",
note=(
"The legacy-era half of the ad/claims coupling: only a handshake connection can exhibit it, so "
"the version window ends where the modern era begins."
),
arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),),
),
"extensions:client:notification-binding-delivery": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic#resulttype",
behavior=(
"A vendor server notification bound by a ClientExtension's NotificationBinding is validated "
"against the binding's params type and delivered to its handler serially, in dispatch order."
),
added_in="2026-07-28",
deferred=(
"Covered at session tier by tests/client/test_session_notification_bindings.py: no public "
"server-side surface emits vendor-method notifications (ServerNotification is a closed union), "
"and HTTP-modern arrival additionally needs the subscriptions/listen client runtime."
),
),
# ═══════════════════════════════════════════════════════════════════════════
# Transports (in-suite coverage)
# ═══════════════════════════════════════════════════════════════════════════
"transport:streamable-http:stateful": Requirement(
@@ -3341,6 +3404,19 @@ REQUIREMENTS: dict[str, Requirement] = {
transports=("streamable-http",),
note="Only observable over streamable HTTP: headers are derived from the cached tool schema at the seam.",
),
"client-transport:http:vendor-name-param-header": Requirement(
source="sdk",
behavior=(
"A vendor request type declaring name_param mirrors that wire-params key into the Mcp-Name "
"header of its outgoing HTTP request, with no client-side registration of the method."
),
added_in="2026-07-28",
transports=("streamable-http",),
note=(
"SDK mechanism honouring the per-extension Mcp-Name requirements (e.g. SEP-2663 mandates the "
"header for tasks/*); only observable over streamable HTTP, where headers exist."
),
),
"client-transport:http:stateless-ignores-session-id": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/transports#stateless-request-headers",
behavior=(
@@ -0,0 +1,174 @@
"""Client extensions (SEP-2133) over the full client-server loop: a server extension
substitutes a claimed `tools/call` shape and the declaring client's `ClientExtension` resolves it."""
from collections.abc import Awaitable, Callable, Sequence
from typing import Any, Literal
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import MISSING_REQUIRED_CLIENT_CAPABILITY, CallToolResult, Result, TextContent
from pydantic import ValidationError
from mcp import MCPError
from mcp.client import ClaimContext, ClientExtension, ResultClaim, advertise
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
from mcp.server.extension import Extension
from mcp.server.mcpserver import Context, MCPServer, require_client_extension
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
_RECEIPTS = "com.example/receipts"
_FLAGS = "com.example/flags"
class ReceiptResult(Result):
result_type: Literal["receipt"] = "receipt"
receipt_token: str
settings_echo: dict[str, Any] | None = None
_Resolver = Callable[[ReceiptResult, ClaimContext], Awaitable[CallToolResult]]
class Receipts(ClientExtension):
"""Client half: claims the `receipt` shape with the test's resolver and settings."""
identifier = _RECEIPTS
def __init__(self, resolve: _Resolver, settings: dict[str, Any] | None = None) -> None:
self._resolve = resolve
self._settings = {} if settings is None else settings
def settings(self) -> dict[str, Any]:
return self._settings
def claims(self) -> Sequence[ResultClaim[Any]]:
return [ResultClaim(result_type="receipt", model=ReceiptResult, resolve=self._resolve)]
class _ReceiptIssuer(Extension):
"""Server half: answers `buy` with the claimed shape; every other tool passes through."""
identifier = _RECEIPTS
async def intercept_tool_call(
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
) -> HandlerResult:
if params.name != "buy":
return await call_next(ctx)
return {"resultType": "receipt", "receiptToken": "r-117"}
def _receipt_shop(issuer: Extension) -> MCPServer:
server = MCPServer("shop", extensions=[issuer])
@server.tool()
def buy(item: str) -> CallToolResult:
"""Buy an item."""
raise NotImplementedError # the server extension answers `buy` before the tool runs
@server.tool()
def redeem(token: str) -> str:
"""Exchange a receipt token for the goods."""
return f"goods for {token}"
return server
@requirement("extensions:client:claimed-result-resolved")
async def test_claimed_result_is_finished_by_the_owning_extensions_resolver(connect: Connect) -> None:
"""The owning extension's claim resolver redeems the substituted `receipt` through
`ctx.session`, and `call_tool` returns the resolver's plain `CallToolResult`."""
received: list[ReceiptResult] = []
async def redeem_receipt(claimed: ReceiptResult, ctx: ClaimContext) -> CallToolResult:
received.append(claimed)
return await ctx.session.call_tool("redeem", {"token": claimed.receipt_token})
async with connect(_receipt_shop(_ReceiptIssuer()), extensions=[Receipts(redeem_receipt)]) as client:
result = await client.call_tool("buy", {"item": "lamp"})
assert [claimed.receipt_token for claimed in received] == ["r-117"]
assert result == snapshot(
CallToolResult(content=[TextContent(text="goods for r-117")], structured_content={"result": "goods for r-117"})
)
@requirement("extensions:client:claimed-result-undeclared-invalid")
async def test_claimed_shape_fails_validation_for_a_client_without_the_extension(connect: Connect) -> None:
"""Spec-mandated: an unrecognized `resultType` is invalid, so a client without the
owning extension fails to parse the claimed shape."""
async with connect(_receipt_shop(_ReceiptIssuer())) as client:
with pytest.raises(ValidationError):
await client.call_tool("buy", {"item": "lamp"})
class _SettingsEchoIssuer(Extension):
"""Server half: requires the declaring client, then echoes its declared settings."""
identifier = _RECEIPTS
async def intercept_tool_call(
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
) -> HandlerResult:
require_client_extension(ctx, _RECEIPTS)
client_params = ctx.session.client_params
assert client_params is not None
extensions = client_params.capabilities.extensions
assert extensions is not None
return {"resultType": "receipt", "receiptToken": "echo", "settingsEcho": extensions[_RECEIPTS]}
@requirement("extensions:client:capability-ad:gates-server-behaviour")
async def test_per_request_ad_carries_settings_and_gates_the_claimed_substitution(connect: Connect) -> None:
"""The per-request `_meta` capability ad gates the claimed substitution: declared
settings reach the resolver and a non-declaring client is refused with -32021."""
server = MCPServer("shop", extensions=[_SettingsEchoIssuer()])
@server.tool()
def buy(item: str) -> CallToolResult:
"""Buy an item."""
raise NotImplementedError # the server extension answers `buy` before the tool runs
received: list[ReceiptResult] = []
async def keep(claimed: ReceiptResult, ctx: ClaimContext) -> CallToolResult:
received.append(claimed)
return CallToolResult(content=[TextContent(text="done")])
async with connect(server, extensions=[Receipts(keep, settings={"tier": "gold"})]) as client:
result = await client.call_tool("buy", {"item": "lamp"})
assert result.content == [TextContent(text="done")]
assert [claimed.settings_echo for claimed in received] == [{"tier": "gold"}]
async with connect(server) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("buy", {"item": "lamp"})
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
async def _unreachable_resolve(claimed: ReceiptResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError # no claimed shape can be delivered on a legacy wire
@requirement("extensions:client:capability-ad:legacy-omits-claimed")
async def test_legacy_ad_omits_claim_bearing_identifiers_but_keeps_claim_less_ones(connect: Connect) -> None:
"""On a legacy connection the claim-bearing identifier drops out of the initialize
capability ad while an ad-only identifier still advertises."""
server = MCPServer("introspector")
@server.tool()
def declared(ctx: Context) -> list[str]:
"""Report the extension identifiers the client advertised."""
capabilities = ctx.client_capabilities
assert capabilities is not None
return sorted(capabilities.extensions or {})
client_extensions = [Receipts(_unreachable_resolve), advertise(_FLAGS)]
async with connect(server, extensions=client_extensions) as client:
result = await client.call_tool("declared", {})
assert result.structured_content == {"result": [_FLAGS]}
@@ -9,7 +9,7 @@ result-envelope shape, so every assertion here is necessarily wire-level.
import json
from collections.abc import Callable
from typing import Any
from typing import Any, Literal
import anyio
import httpx
@@ -30,7 +30,9 @@ from mcp_types import (
JSONRPCResponse,
ListToolsResult,
PaginatedRequestParams,
Request,
RequestParams,
Result,
ServerCapabilities,
TextContent,
Tool,
@@ -551,3 +553,56 @@ async def test_modern_client_stops_mirroring_after_a_re_list_drops_the_tool() ->
before, after = tool_calls
assert before.headers.get("mcp-param-region") == "x"
assert not any(k.startswith("mcp-param-") for k in after.headers)
class _JobParams(RequestParams):
job_id: str
class _JobStatusRequest(Request[_JobParams, Literal["com.example/jobs.status"]]):
method: Literal["com.example/jobs.status"] = "com.example/jobs.status"
name_param = "jobId"
class _JobStatusResult(Result):
status: str
@requirement("client-transport:http:vendor-name-param-header")
async def test_vendor_request_with_name_param_carries_mcp_name_on_the_wire() -> None:
"""`send_request` mirrors an unregistered vendor request's `name_param` value into the
`Mcp-Name` header while the body keeps the params key unchanged."""
async def job_status(ctx: ServerRequestContext, params: _JobParams) -> _JobStatusResult:
assert params.job_id == "job-7"
return _JobStatusResult(status="running")
server = _server()
server.add_request_handler("com.example/jobs.status", _JobParams, job_status)
requests: list[httpx.Request] = []
async def on_request(request: httpx.Request) -> None:
requests.append(request)
discover = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="srv", version="0"),
)
with anyio.fail_after(5):
async with (
mounted_app(server, on_request=on_request) as (http, _),
Client(
streamable_http_client(f"{BASE_URL}/mcp", http_client=http),
mode=LATEST_MODERN_VERSION,
prior_discover=discover,
) as client,
):
request = _JobStatusRequest(params=_JobParams(job_id="job-7"))
result = await client.session.send_request(request, _JobStatusResult)
assert result.status == "running"
[wire_request] = requests
assert wire_request.headers["mcp-name"] == "job-7"
assert json.loads(wire_request.content)["params"]["jobId"] == "job-7"
+5 -49
View File
@@ -18,6 +18,7 @@ from mcp_types import (
TextContent,
)
from mcp.client import advertise
from mcp.client.client import Client
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
from mcp.server.extension import (
@@ -26,7 +27,6 @@ from mcp.server.extension import (
ResourceBinding,
ToolBinding,
compose_tool_call_interceptor,
validate_extension_identifier,
)
from mcp.server.mcpserver import Context, MCPServer, require_client_extension
from mcp.server.mcpserver.resources import TextResource
@@ -193,7 +193,7 @@ async def test_extension_method_reachable_via_session_send_request() -> None:
async with Client(server) as client:
request = _PingRequest(params=_PingParams())
result = await client.session.send_request(cast("types.ClientRequest", request), _PingResult)
result = await client.session.send_request(request, _PingResult)
assert result == snapshot(_PingResult(pong=True))
@@ -343,7 +343,7 @@ async def test_version_pinned_method_is_served_at_an_allowed_version() -> None:
async with Client(server, mode="2026-07-28") as client:
request = _VersionPinnedRequest(params=_VersionPinnedParams())
result = await client.session.send_request(cast("types.ClientRequest", request), _VersionPinnedResult)
result = await client.session.send_request(request, _VersionPinnedResult)
assert result == snapshot(_VersionPinnedResult(ok=True))
@@ -356,56 +356,12 @@ async def test_version_pinned_method_is_method_not_found_at_a_disallowed_version
async with Client(server, mode="legacy") as client:
request = _VersionPinnedRequest(params=_VersionPinnedParams())
with pytest.raises(MCPError) as exc_info:
await client.session.send_request(cast("types.ClientRequest", request), _VersionPinnedResult)
await client.session.send_request(request, _VersionPinnedResult)
assert exc_info.value.code == METHOD_NOT_FOUND
assert exc_info.value.error.data == "com.example/pinned"
@pytest.mark.parametrize(
"identifier",
[
"io.modelcontextprotocol/ui",
"com.example/my_ext",
"com.x-y.z2/n.a-b_c",
"example/x",
"a/b",
"com.example/9start",
],
)
def test_grammar_conformant_extension_identifiers_are_accepted(identifier: str) -> None:
"""Spec `_meta` key grammar: dot-separated labels (letter start, letter/digit end,
hyphens interior), a slash, then a name that starts and ends alphanumeric."""
validate_extension_identifier(identifier, owner="T")
@pytest.mark.parametrize(
"identifier",
[
"noprefix",
"-foo/bar",
".leading/x",
"a..b/x",
"foo-/x",
"9foo/x",
"foo/-bar",
"foo/bar-",
"foo/",
"/bar",
"foo/ba r",
"io.modelcontextprotocol/ui\n",
"",
None,
42,
],
)
def test_malformed_extension_identifiers_are_rejected(identifier: Any) -> None:
"""Spec `_meta` key grammar: malformed prefixes (bad label start/end, empty labels)
and malformed names are rejected, as are non-strings."""
with pytest.raises(TypeError):
validate_extension_identifier(identifier, owner="T")
@pytest.mark.parametrize("method", ["tools/list", "completion/complete"])
def test_method_binding_rejects_spec_methods(method: str) -> None:
"""SDK-defined: extension methods are additive — binding a spec-defined request method
@@ -466,7 +422,7 @@ async def test_require_client_extension_passes_when_client_declared_it() -> None
"""SDK-defined: `require_client_extension` is a no-op when the client advertised the id."""
server = MCPServer("test", extensions=[_RequiresExt()])
async with Client(server, extensions={_NEEDS_EXT: {}}) as client:
async with Client(server, extensions=[advertise(_NEEDS_EXT)]) as client:
result = await client.call_tool("guarded", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"}))
+11 -9
View File
@@ -14,6 +14,7 @@ import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, ReadResourceResult, TextContent, TextResourceContents
from mcp.client import advertise
from mcp.client.client import Client
from mcp.server import Server, ServerRequestContext
from mcp.server.apps import (
@@ -95,7 +96,7 @@ async def test_apps_tool_returns_rich_output_when_client_negotiated_apps() -> No
branching on `client_supports_apps(ctx)`, drives both halves."""
server = _clock_server()
async with Client(server, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as supports:
async with Client(server, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]) as supports:
rich = await supports.call_tool("get_time", {})
async with Client(server) as plain:
fallback = await plain.call_tool("get_time", {})
@@ -104,7 +105,7 @@ async def test_apps_tool_returns_rich_output_when_client_negotiated_apps() -> No
assert fallback.content == snapshot([TextContent(text="The time is 2026-06-26T00:00:00Z.")])
async def _observed_client_supports_apps(extensions: dict[str, dict[str, Any]] | None) -> bool:
async def _observed_client_supports_apps(ui_settings: dict[str, Any] | None) -> bool:
"""Run one probe `tools/call` and report what `client_supports_apps` saw server-side.
Exercises the lowlevel `ServerRequestContext` form, which reads the client's
@@ -123,29 +124,30 @@ async def _observed_client_supports_apps(extensions: dict[str, dict[str, Any]] |
return CallToolResult(content=[TextContent(text="ok")])
server = Server("probe", on_list_tools=list_tools, on_call_tool=call_tool)
extensions = None if ui_settings is None else [advertise(EXTENSION_ID, ui_settings)]
async with Client(server, extensions=extensions) as client:
await client.call_tool("probe", {})
return observed[0]
@pytest.mark.parametrize(
("extensions", "expected"),
("ui_settings", "expected"),
[
pytest.param({EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}, True, id="html-mime-listed"),
pytest.param({EXTENSION_ID: {"mimeTypes": (APP_MIME_TYPE,)}}, True, id="in-process-tuple-mime-types"),
pytest.param({"mimeTypes": [APP_MIME_TYPE]}, True, id="html-mime-listed"),
pytest.param({"mimeTypes": (APP_MIME_TYPE,)}, True, id="in-process-tuple-mime-types"),
pytest.param(None, False, id="extension-not-declared"),
pytest.param({EXTENSION_ID: {"mimeTypes": ["application/x-other"]}}, False, id="html-mime-not-offered"),
pytest.param({EXTENSION_ID: {}}, False, id="mime-types-key-missing"),
pytest.param({"mimeTypes": ["application/x-other"]}, False, id="html-mime-not-offered"),
pytest.param({}, False, id="mime-types-key-missing"),
],
)
async def test_client_supports_apps_from_lowlevel_request_context(
extensions: dict[str, dict[str, Any]] | None, expected: bool
ui_settings: dict[str, Any] | None, expected: bool
) -> None:
"""ext-apps: `client_supports_apps` is `True` only when the client declared the ui
extension AND listed `text/html;profile=mcp-app` in its `mimeTypes` settings a
required field, so its absence means unsupported (the reference SDK's check is
`uiCap?.mimeTypes?.includes(...)`)."""
assert await _observed_client_supports_apps(extensions) is expected
assert await _observed_client_supports_apps(ui_settings) is expected
def test_apps_tool_rejects_non_ui_resource_uri() -> None:
+3 -2
View File
@@ -13,6 +13,7 @@ import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp.client import advertise
from mcp.client.client import Client
from mcp.server import Server, ServerRequestContext
from mcp.server.extension import Extension
@@ -82,7 +83,7 @@ async def test_server_accepts_capability_for_client_advertised_extension() -> No
return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})])
server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools)
async with Client(server, extensions={_EXTENSION_ID: {"mimeTypes": ["text/html"]}}) as client:
async with Client(server, extensions=[advertise(_EXTENSION_ID, {"mimeTypes": ["text/html"]})]) as client:
await client.call_tool("probe", {})
assert supported == [True]
@@ -105,7 +106,7 @@ async def test_server_rejects_capability_for_undeclared_extension() -> None:
return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})])
server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools)
async with Client(server, extensions={_EXTENSION_ID: {"mimeTypes": ["text/html"]}}) as client:
async with Client(server, extensions=[advertise(_EXTENSION_ID, {"mimeTypes": ["text/html"]})]) as client:
await client.call_tool("probe", {})
assert supported == [False]
+56
View File
@@ -0,0 +1,56 @@
"""The extension-identifier grammar in `mcp.shared.extension`, shared by server and client."""
from typing import Any
import pytest
import mcp.server.extension
import mcp.shared.extension
from mcp.shared.extension import validate_extension_identifier
def test_server_extension_module_reexports_shared_validator() -> None:
"""SDK-defined: `mcp.server.extension` re-exports the shared validator as the same function object."""
assert mcp.server.extension.validate_extension_identifier is mcp.shared.extension.validate_extension_identifier
@pytest.mark.parametrize(
"identifier",
[
"io.modelcontextprotocol/ui",
"com.example/my_ext",
"com.x-y.z2/n.a-b_c",
"example/x",
"a/b",
"com.example/9start",
],
)
def test_grammar_conformant_extension_identifiers_are_accepted(identifier: str) -> None:
"""Spec `_meta` key grammar: conformant `vendor-prefix/name` identifiers are accepted."""
validate_extension_identifier(identifier, owner="T")
@pytest.mark.parametrize(
"identifier",
[
"noprefix",
"-foo/bar",
".leading/x",
"a..b/x",
"foo-/x",
"9foo/x",
"foo/-bar",
"foo/bar-",
"foo/",
"/bar",
"foo/ba r",
"io.modelcontextprotocol/ui\n",
"",
None,
42,
],
)
def test_malformed_extension_identifiers_are_rejected(identifier: Any) -> None:
"""Spec `_meta` key grammar: malformed prefixes, malformed names, and non-strings are rejected."""
with pytest.raises(TypeError):
validate_extension_identifier(identifier, owner="T")
+37
View File
@@ -0,0 +1,37 @@
"""`Request.name_param`: the wire-params key a request type declares for `Mcp-Name` emission."""
from typing import Literal
import mcp_types as types
from mcp_types import CallToolRequest, PingRequest, Request
class _VendorParams(types.RequestParams):
task_id: str
class _VendorRequest(Request[_VendorParams, Literal["vendor/tasks/get"]]):
method: Literal["vendor/tasks/get"] = "vendor/tasks/get"
name_param = "taskId"
def test_request_base_declares_no_name_param() -> None:
assert Request.name_param is None
def test_core_request_types_inherit_none() -> None:
assert CallToolRequest.name_param is None
assert PingRequest.name_param is None
def test_subclass_overrides_by_bare_assignment() -> None:
"""Subclasses set `name_param` by bare assignment; the override is class-local."""
assert _VendorRequest.name_param == "taskId"
assert Request.name_param is None
def test_name_param_is_not_a_pydantic_field() -> None:
request = _VendorRequest(params=_VendorParams(task_id="t-1"))
assert "name_param" not in _VendorRequest.model_fields
dumped = request.model_dump(by_alias=True, mode="json", exclude_none=True)
assert dumped == {"method": "vendor/tasks/get", "params": {"taskId": "t-1"}}