Files
modelcontextprotocol--pytho…/tests/client/test_client_extensions.py
T
wehub-resource-sync 49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

574 lines
22 KiB
Python

"""`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