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