49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
175 lines
7.1 KiB
Python
175 lines
7.1 KiB
Python
"""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]}
|