chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 12:10:27 +08:00
commit 49b9bb6724
992 changed files with 161690 additions and 0 deletions
View File
@@ -0,0 +1,503 @@
import urllib.parse
import warnings
import jwt
import pytest
from pydantic import AnyHttpUrl, AnyUrl
from mcp.client.auth.extensions.client_credentials import (
ClientCredentialsOAuthProvider,
JWTParameters,
PrivateKeyJWTOAuthProvider,
RFC7523OAuthClientProvider,
SignedJWTParameters,
static_assertion_provider,
)
from mcp.shared.auth import (
AuthorizationCodeResult,
OAuthClientInformationFull,
OAuthClientMetadata,
OAuthMetadata,
OAuthToken,
)
from mcp.shared.exceptions import MCPDeprecationWarning
class MockTokenStorage:
"""Mock token storage for testing."""
def __init__(self):
self._tokens: OAuthToken | None = None
self._client_info: OAuthClientInformationFull | None = None
async def get_tokens(self) -> OAuthToken | None:
return self._tokens
async def set_tokens(self, tokens: OAuthToken) -> None: # pragma: no cover
self._tokens = tokens
async def get_client_info(self) -> OAuthClientInformationFull | None: # pragma: no cover
return self._client_info
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: # pragma: no cover
self._client_info = client_info
@pytest.fixture
def mock_storage():
return MockTokenStorage()
@pytest.fixture
def client_metadata():
return OAuthClientMetadata(
client_name="Test Client",
client_uri=AnyHttpUrl("https://example.com"),
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
scope="read write",
)
@pytest.fixture
def rfc7523_oauth_provider(client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage):
async def redirect_handler(url: str) -> None: # pragma: no cover
"""Mock redirect handler."""
pass
async def callback_handler() -> AuthorizationCodeResult: # pragma: no cover
"""Mock callback handler."""
return AuthorizationCodeResult(code="test_auth_code", state="test_state")
with warnings.catch_warnings():
warnings.simplefilter("ignore", MCPDeprecationWarning)
return RFC7523OAuthClientProvider(
server_url="https://api.example.com/v1/mcp",
client_metadata=client_metadata,
storage=mock_storage,
redirect_handler=redirect_handler,
callback_handler=callback_handler,
)
class TestOAuthFlowClientCredentials:
"""Test OAuth flow behavior for client credentials flows."""
@pytest.mark.anyio
async def test_token_exchange_request_jwt_predefined(self, rfc7523_oauth_provider: RFC7523OAuthClientProvider):
"""Test token exchange request building with a predefined JWT assertion."""
# Set up required context
rfc7523_oauth_provider.context.client_info = OAuthClientInformationFull(
grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"],
token_endpoint_auth_method="private_key_jwt",
redirect_uris=None,
scope="read write",
)
rfc7523_oauth_provider.context.oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://api.example.com"),
authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"),
token_endpoint=AnyHttpUrl("https://api.example.com/token"),
registration_endpoint=AnyHttpUrl("https://api.example.com/register"),
)
rfc7523_oauth_provider.context.client_metadata = rfc7523_oauth_provider.context.client_info
rfc7523_oauth_provider.context.protocol_version = "2025-06-18"
rfc7523_oauth_provider.jwt_parameters = JWTParameters(
# https://www.jwt.io
assertion="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30"
)
request = await rfc7523_oauth_provider._exchange_token_jwt_bearer()
assert request.method == "POST"
assert str(request.url) == "https://api.example.com/token"
assert request.headers["Content-Type"] == "application/x-www-form-urlencoded"
# Check form data
content = urllib.parse.unquote_plus(request.content.decode())
assert "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" in content
assert "scope=read write" in content
assert "resource=https://api.example.com/v1/mcp" in content
assert (
"assertion=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30"
in content
)
@pytest.mark.anyio
async def test_token_exchange_request_jwt(self, rfc7523_oauth_provider: RFC7523OAuthClientProvider):
"""Test token exchange request building wiith a generated JWT assertion."""
# Set up required context
rfc7523_oauth_provider.context.client_info = OAuthClientInformationFull(
grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"],
token_endpoint_auth_method="private_key_jwt",
redirect_uris=None,
scope="read write",
)
rfc7523_oauth_provider.context.oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://api.example.com"),
authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"),
token_endpoint=AnyHttpUrl("https://api.example.com/token"),
registration_endpoint=AnyHttpUrl("https://api.example.com/register"),
)
rfc7523_oauth_provider.context.client_metadata = rfc7523_oauth_provider.context.client_info
rfc7523_oauth_provider.context.protocol_version = "2025-06-18"
rfc7523_oauth_provider.jwt_parameters = JWTParameters(
issuer="foo",
subject="1234567890",
claims={
"name": "John Doe",
"admin": True,
"iat": 1516239022,
},
jwt_signing_algorithm="HS256",
jwt_signing_key="a-string-secret-at-least-256-bits-long",
jwt_lifetime_seconds=300,
)
request = await rfc7523_oauth_provider._exchange_token_jwt_bearer()
assert request.method == "POST"
assert str(request.url) == "https://api.example.com/token"
assert request.headers["Content-Type"] == "application/x-www-form-urlencoded"
# Check form data
content = urllib.parse.unquote_plus(request.content.decode()).split("&")
assert "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" in content
assert "scope=read write" in content
assert "resource=https://api.example.com/v1/mcp" in content
# Check assertion
assertion = next(param for param in content if param.startswith("assertion="))[len("assertion=") :]
claims = jwt.decode(
assertion,
key="a-string-secret-at-least-256-bits-long",
algorithms=["HS256"],
audience="https://api.example.com/",
subject="1234567890",
issuer="foo",
verify=True,
)
assert claims["name"] == "John Doe"
assert claims["admin"]
assert claims["iat"] == 1516239022
class TestClientCredentialsOAuthProvider:
"""Test ClientCredentialsOAuthProvider."""
@pytest.mark.anyio
async def test_init_sets_client_info(self, mock_storage: MockTokenStorage):
"""Test that _initialize sets client_info."""
provider = ClientCredentialsOAuthProvider(
server_url="https://api.example.com",
storage=mock_storage,
client_id="test-client-id",
client_secret="test-client-secret",
)
# client_info is set during _initialize
await provider._initialize()
assert provider.context.client_info is not None
assert provider.context.client_info.client_id == "test-client-id"
assert provider.context.client_info.client_secret == "test-client-secret"
assert provider.context.client_info.grant_types == ["client_credentials"]
assert provider.context.client_info.token_endpoint_auth_method == "client_secret_basic"
@pytest.mark.anyio
async def test_init_with_scopes(self, mock_storage: MockTokenStorage):
"""Test that constructor accepts scopes."""
provider = ClientCredentialsOAuthProvider(
server_url="https://api.example.com",
storage=mock_storage,
client_id="test-client-id",
client_secret="test-client-secret",
scopes="read write",
)
await provider._initialize()
assert provider.context.client_info is not None
assert provider.context.client_info.scope == "read write"
@pytest.mark.anyio
async def test_init_with_client_secret_post(self, mock_storage: MockTokenStorage):
"""Test that constructor accepts client_secret_post auth method."""
provider = ClientCredentialsOAuthProvider(
server_url="https://api.example.com",
storage=mock_storage,
client_id="test-client-id",
client_secret="test-client-secret",
token_endpoint_auth_method="client_secret_post",
)
await provider._initialize()
assert provider.context.client_info is not None
assert provider.context.client_info.token_endpoint_auth_method == "client_secret_post"
@pytest.mark.anyio
async def test_exchange_token_client_credentials(self, mock_storage: MockTokenStorage):
"""Test token exchange request building."""
provider = ClientCredentialsOAuthProvider(
server_url="https://api.example.com/v1/mcp",
storage=mock_storage,
client_id="test-client-id",
client_secret="test-client-secret",
scopes="read write",
)
provider.context.oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://api.example.com"),
authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"),
token_endpoint=AnyHttpUrl("https://api.example.com/token"),
)
provider.context.protocol_version = "2025-06-18"
request = await provider._perform_authorization()
assert request.method == "POST"
assert str(request.url) == "https://api.example.com/token"
content = urllib.parse.unquote_plus(request.content.decode())
assert "grant_type=client_credentials" in content
assert "scope=read write" in content
assert "resource=https://api.example.com/v1/mcp" in content
@pytest.mark.anyio
async def test_exchange_token_client_secret_post_includes_client_id(self, mock_storage: MockTokenStorage):
"""Test that client_secret_post includes both client_id and client_secret in body (RFC 6749 §2.3.1)."""
provider = ClientCredentialsOAuthProvider(
server_url="https://api.example.com/v1/mcp",
storage=mock_storage,
client_id="test-client-id",
client_secret="test-client-secret",
token_endpoint_auth_method="client_secret_post",
scopes="read write",
)
await provider._initialize()
provider.context.oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://api.example.com"),
authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"),
token_endpoint=AnyHttpUrl("https://api.example.com/token"),
)
provider.context.protocol_version = "2025-06-18"
request = await provider._perform_authorization()
content = urllib.parse.unquote_plus(request.content.decode())
assert "grant_type=client_credentials" in content
assert "client_id=test-client-id" in content
assert "client_secret=test-client-secret" in content
# Should NOT have Basic auth header
assert "Authorization" not in request.headers
@pytest.mark.anyio
async def test_exchange_token_client_secret_post_without_client_id(self, mock_storage: MockTokenStorage):
"""Test client_secret_post skips body credentials when client_id is None."""
provider = ClientCredentialsOAuthProvider(
server_url="https://api.example.com/v1/mcp",
storage=mock_storage,
client_id="placeholder",
client_secret="test-client-secret",
token_endpoint_auth_method="client_secret_post",
scopes="read write",
)
await provider._initialize()
provider.context.oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://api.example.com"),
authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"),
token_endpoint=AnyHttpUrl("https://api.example.com/token"),
)
provider.context.protocol_version = "2025-06-18"
# Override client_info to have client_id=None (edge case)
provider.context.client_info = OAuthClientInformationFull(
redirect_uris=None,
client_id=None,
client_secret="test-client-secret",
grant_types=["client_credentials"],
token_endpoint_auth_method="client_secret_post",
scope="read write",
)
request = await provider._perform_authorization()
content = urllib.parse.unquote_plus(request.content.decode())
assert "grant_type=client_credentials" in content
# Neither client_id nor client_secret should be in body since client_id is None
# (RFC 6749 §2.3.1 requires both for client_secret_post)
assert "client_id=" not in content
assert "client_secret=" not in content
assert "Authorization" not in request.headers
@pytest.mark.anyio
async def test_exchange_token_without_scopes(self, mock_storage: MockTokenStorage):
"""Test token exchange without scopes."""
provider = ClientCredentialsOAuthProvider(
server_url="https://api.example.com/v1/mcp",
storage=mock_storage,
client_id="test-client-id",
client_secret="test-client-secret",
)
provider.context.oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://api.example.com"),
authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"),
token_endpoint=AnyHttpUrl("https://api.example.com/token"),
)
provider.context.protocol_version = "2024-11-05" # Old version - no resource param
request = await provider._perform_authorization()
content = urllib.parse.unquote_plus(request.content.decode())
assert "grant_type=client_credentials" in content
assert "scope=" not in content
assert "resource=" not in content
class TestPrivateKeyJWTOAuthProvider:
"""Test PrivateKeyJWTOAuthProvider."""
@pytest.mark.anyio
async def test_init_sets_client_info(self, mock_storage: MockTokenStorage):
"""Test that _initialize sets client_info."""
async def mock_assertion_provider(audience: str) -> str: # pragma: no cover
return "mock-jwt"
provider = PrivateKeyJWTOAuthProvider(
server_url="https://api.example.com",
storage=mock_storage,
client_id="test-client-id",
assertion_provider=mock_assertion_provider,
)
# client_info is set during _initialize
await provider._initialize()
assert provider.context.client_info is not None
assert provider.context.client_info.client_id == "test-client-id"
assert provider.context.client_info.grant_types == ["client_credentials"]
assert provider.context.client_info.token_endpoint_auth_method == "private_key_jwt"
@pytest.mark.anyio
async def test_exchange_token_client_credentials(self, mock_storage: MockTokenStorage):
"""Test token exchange request building with assertion provider."""
async def mock_assertion_provider(audience: str) -> str:
return f"jwt-for-{audience}"
provider = PrivateKeyJWTOAuthProvider(
server_url="https://api.example.com/v1/mcp",
storage=mock_storage,
client_id="test-client-id",
assertion_provider=mock_assertion_provider,
scopes="read write",
)
provider.context.oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://auth.example.com"),
authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"),
token_endpoint=AnyHttpUrl("https://auth.example.com/token"),
)
provider.context.protocol_version = "2025-06-18"
request = await provider._perform_authorization()
assert request.method == "POST"
assert str(request.url) == "https://auth.example.com/token"
content = urllib.parse.unquote_plus(request.content.decode())
assert "grant_type=client_credentials" in content
assert "client_assertion=jwt-for-https://auth.example.com/" in content
assert "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" in content
assert "scope=read write" in content
@pytest.mark.anyio
async def test_exchange_token_without_scopes(self, mock_storage: MockTokenStorage):
"""Test token exchange without scopes."""
async def mock_assertion_provider(audience: str) -> str:
return f"jwt-for-{audience}"
provider = PrivateKeyJWTOAuthProvider(
server_url="https://api.example.com/v1/mcp",
storage=mock_storage,
client_id="test-client-id",
assertion_provider=mock_assertion_provider,
)
provider.context.oauth_metadata = OAuthMetadata(
issuer=AnyHttpUrl("https://auth.example.com"),
authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"),
token_endpoint=AnyHttpUrl("https://auth.example.com/token"),
)
provider.context.protocol_version = "2024-11-05" # Old version - no resource param
request = await provider._perform_authorization()
content = urllib.parse.unquote_plus(request.content.decode())
assert "grant_type=client_credentials" in content
assert "scope=" not in content
assert "resource=" not in content
class TestSignedJWTParameters:
"""Test SignedJWTParameters."""
@pytest.mark.anyio
async def test_create_assertion_provider(self):
"""Test that create_assertion_provider creates valid JWTs."""
params = SignedJWTParameters(
issuer="test-issuer",
subject="test-subject",
signing_key="a-string-secret-at-least-256-bits-long",
signing_algorithm="HS256",
lifetime_seconds=300,
)
provider = params.create_assertion_provider()
assertion = await provider("https://auth.example.com")
claims = jwt.decode(
assertion,
key="a-string-secret-at-least-256-bits-long",
algorithms=["HS256"],
audience="https://auth.example.com",
)
assert claims["iss"] == "test-issuer"
assert claims["sub"] == "test-subject"
assert claims["aud"] == "https://auth.example.com"
assert "exp" in claims
assert "iat" in claims
assert "jti" in claims
@pytest.mark.anyio
async def test_create_assertion_provider_with_additional_claims(self):
"""Test that additional_claims are included in the JWT."""
params = SignedJWTParameters(
issuer="test-issuer",
subject="test-subject",
signing_key="a-string-secret-at-least-256-bits-long",
signing_algorithm="HS256",
additional_claims={"custom": "value"},
)
provider = params.create_assertion_provider()
assertion = await provider("https://auth.example.com")
claims = jwt.decode(
assertion,
key="a-string-secret-at-least-256-bits-long",
algorithms=["HS256"],
audience="https://auth.example.com",
)
assert claims["custom"] == "value"
class TestStaticAssertionProvider:
"""Test static_assertion_provider helper."""
@pytest.mark.anyio
async def test_returns_static_token(self):
"""Test that static_assertion_provider returns the same token regardless of audience."""
token = "my-static-jwt-token"
provider = static_assertion_provider(token)
result1 = await provider("https://auth1.example.com")
result2 = await provider("https://auth2.example.com")
assert result1 == token
assert result2 == token
@@ -0,0 +1,411 @@
"""Unit tests for the standalone SEP-990 jwt-bearer `httpx.Auth`.
The provider's authorization server is configuration; these tests assert that authorization-server
metadata is fetched only from the configured issuer, that the resource server is never consulted for
AS selection, and that the ID-JAG and client secret reach only the issuer's token endpoint.
"""
import base64
import json
import urllib.parse
import httpx
import pytest
from mcp.client.auth import OAuthFlowError, OAuthTokenError
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider, _origin
from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken
ISSUER = "https://auth.example.com"
RS = "https://mcp.example.com"
ASM_PATH = "/.well-known/oauth-authorization-server"
OIDC_PATH = "/.well-known/openid-configuration"
class InMemoryStorage:
def __init__(self, tokens: OAuthToken | None = None) -> None:
self.tokens = tokens
async def get_tokens(self) -> OAuthToken | None:
return self.tokens
async def set_tokens(self, tokens: OAuthToken) -> None:
self.tokens = tokens
async def get_client_info(self) -> OAuthClientInformationFull | None:
raise NotImplementedError
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
raise NotImplementedError
def asm_body(*, issuer: str = ISSUER, token_endpoint: str | None = None) -> bytes:
return json.dumps(
{
"issuer": issuer,
"authorization_endpoint": f"{issuer}/authorize",
"token_endpoint": token_endpoint or f"{issuer}/token",
}
).encode()
def token_body(*, access_token: str = "issued-token", scope: str | None = None) -> bytes:
payload: dict[str, object] = {"access_token": access_token, "token_type": "Bearer", "expires_in": 3600}
if scope is not None:
payload["scope"] = scope
return json.dumps(payload).encode()
def make_provider(
storage: InMemoryStorage | None = None,
*,
scope: str | None = "mcp",
token_endpoint_auth_method: str = "client_secret_post",
record: list[tuple[str, str]] | None = None,
) -> IdentityAssertionOAuthProvider:
async def assertion_provider(audience: str, resource: str) -> str:
if record is not None:
record.append((audience, resource))
return "the-id-jag"
return IdentityAssertionOAuthProvider(
server_url=f"{RS}/mcp",
storage=storage if storage is not None else InMemoryStorage(),
client_id="test-client-id",
client_secret="test-client-secret",
issuer=ISSUER,
assertion_provider=assertion_provider,
scope=scope,
token_endpoint_auth_method=token_endpoint_auth_method, # type: ignore[arg-type]
)
def mock_transport(
requests: list[httpx.Request],
*,
asm: bytes | int = 200,
token: bytes | int = 200,
rs_first_status: int = 401,
rs_first_headers: dict[str, str] | None = None,
) -> httpx.MockTransport:
"""Build a `MockTransport` that records every request and serves the configured ASM and token.
`asm` / `token` are either a body (served as 200 JSON) or an int status (served with no body).
The MCP resource server's first response is `rs_first_status` (default 401) with optional
headers; subsequent RS requests return 200.
"""
rs_hits = 0
def handle(request: httpx.Request) -> httpx.Response:
nonlocal rs_hits
requests.append(request)
host, path = request.url.host, request.url.path
if host == "mcp.example.com":
rs_hits += 1
if rs_hits == 1:
return httpx.Response(rs_first_status, headers=rs_first_headers or {})
return httpx.Response(200, json={"ok": True})
if host == "auth.example.com" and path in (ASM_PATH, OIDC_PATH):
if isinstance(asm, int):
return httpx.Response(asm)
return httpx.Response(200, content=asm, headers={"content-type": "application/json"})
if host == "auth.example.com" and path == "/token":
if isinstance(token, int):
return httpx.Response(token, json={"error": "invalid_grant"})
return httpx.Response(200, content=token, headers={"content-type": "application/json"})
raise AssertionError(f"unexpected request: {request.method} {request.url}") # pragma: no cover
return httpx.MockTransport(handle)
def form(request: httpx.Request) -> dict[str, str]:
return dict(urllib.parse.parse_qsl(request.content.decode()))
@pytest.mark.anyio
async def test_on_401_exchanges_assertion_at_configured_issuer_and_retries() -> None:
"""A 401 fetches ASM from the configured issuer, posts the jwt-bearer grant, and retries."""
requests: list[httpx.Request] = []
record: list[tuple[str, str]] = []
storage = InMemoryStorage()
auth = make_provider(storage, record=record)
async with httpx.AsyncClient(
transport=mock_transport(requests, asm=asm_body(), token=token_body(scope="mcp")), auth=auth
) as http:
response = await http.post(f"{RS}/mcp")
assert [(r.method, str(r.url)) for r in requests] == [
("POST", f"{RS}/mcp"),
("GET", f"{ISSUER}{ASM_PATH}"),
("POST", f"{ISSUER}/token"),
("POST", f"{RS}/mcp"),
]
body = form(requests[2])
assert body == {
"grant_type": JWT_BEARER_GRANT_TYPE,
"assertion": "the-id-jag",
"client_id": "test-client-id",
"resource": f"{RS}/mcp",
"scope": "mcp",
"client_secret": "test-client-secret",
}
assert "Authorization" not in requests[2].headers
assert record == [(ISSUER, f"{RS}/mcp")]
assert response.status_code == 200
assert storage.tokens is not None
assert storage.tokens.access_token == "issued-token"
assert storage.tokens.scope == "mcp"
@pytest.mark.anyio
async def test_resource_server_metadata_is_never_consulted() -> None:
"""No PRM well-known and no RS-origin ASM well-known is ever fetched.
This is the by-construction property: the AS is configuration, so the resource server has no
input into where the ID-JAG or client secret go. Any GET to the RS host fails the test.
"""
requests: list[httpx.Request] = []
auth = make_provider()
async with httpx.AsyncClient(
transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth
) as http:
await http.post(f"{RS}/mcp")
rs_gets = [r for r in requests if r.url.host == "mcp.example.com" and r.method == "GET"]
assert rs_gets == []
assert all(r.url.host == "auth.example.com" for r in requests if r.method == "GET")
# No DCR was attempted anywhere.
assert not any(r.url.path == "/register" for r in requests)
@pytest.mark.anyio
async def test_asm_404_at_configured_issuer_raises_before_minting_assertion() -> None:
"""If the issuer's well-knowns 404, the flow fails closed and the assertion is never minted."""
requests: list[httpx.Request] = []
record: list[tuple[str, str]] = []
auth = make_provider(record=record)
async with httpx.AsyncClient(transport=mock_transport(requests, asm=404), auth=auth) as http:
with pytest.raises(OAuthFlowError, match="No authorization server metadata"):
await http.post(f"{RS}/mcp")
# Both RFC 8414 and OIDC well-knowns were tried at the configured issuer; nothing else.
assert [str(r.url) for r in requests if r.method == "GET"] == [f"{ISSUER}{ASM_PATH}", f"{ISSUER}{OIDC_PATH}"]
assert record == []
assert not any(r.url.path == "/token" for r in requests)
@pytest.mark.anyio
async def test_asm_5xx_stops_discovery_and_raises() -> None:
"""A 5xx at the issuer's well-known stops discovery without trying further URLs."""
requests: list[httpx.Request] = []
auth = make_provider()
async with httpx.AsyncClient(transport=mock_transport(requests, asm=500), auth=auth) as http:
with pytest.raises(OAuthFlowError, match="No authorization server metadata"):
await http.post(f"{RS}/mcp")
assert [str(r.url) for r in requests if r.method == "GET"] == [f"{ISSUER}{ASM_PATH}"]
@pytest.mark.anyio
async def test_asm_with_wrong_issuer_is_rejected_before_minting_assertion() -> None:
"""RFC 8414 section 3.3: metadata whose `issuer` differs from the configured one is rejected."""
requests: list[httpx.Request] = []
record: list[tuple[str, str]] = []
auth = make_provider(record=record)
async with httpx.AsyncClient(
transport=mock_transport(requests, asm=asm_body(issuer="https://other.example")), auth=auth
) as http:
with pytest.raises(OAuthFlowError, match="issuer mismatch"):
await http.post(f"{RS}/mcp")
assert record == []
assert not any(r.url.path == "/token" for r in requests)
@pytest.mark.anyio
async def test_asm_with_off_origin_token_endpoint_is_rejected_before_minting_assertion() -> None:
"""A `token_endpoint` off the configured issuer's origin is refused before any credential is sent."""
requests: list[httpx.Request] = []
record: list[tuple[str, str]] = []
auth = make_provider(record=record)
async with httpx.AsyncClient(
transport=mock_transport(requests, asm=asm_body(token_endpoint="https://other.example/token")), auth=auth
) as http:
with pytest.raises(OAuthFlowError, match="not on the configured issuer origin"):
await http.post(f"{RS}/mcp")
assert record == []
assert not any(r.url.path == "/token" for r in requests)
@pytest.mark.anyio
async def test_403_insufficient_scope_unions_challenged_scope_with_configured() -> None:
"""A 403 `insufficient_scope` re-exchanges with the union of configured and challenged scopes."""
requests: list[httpx.Request] = []
auth = make_provider(scope="mcp")
transport = mock_transport(
requests,
asm=asm_body(),
token=token_body(),
rs_first_status=403,
rs_first_headers={"WWW-Authenticate": 'Bearer error="insufficient_scope", scope="mcp files:write"'},
)
async with httpx.AsyncClient(transport=transport, auth=auth) as http:
response = await http.post(f"{RS}/mcp")
[token_req] = [r for r in requests if r.url.path == "/token"]
assert form(token_req)["scope"] == "mcp files:write"
assert response.status_code == 200
@pytest.mark.anyio
async def test_403_without_insufficient_scope_does_not_reauthorize() -> None:
"""A plain 403 (not `insufficient_scope`) is returned to the caller without re-exchanging."""
requests: list[httpx.Request] = []
record: list[tuple[str, str]] = []
auth = make_provider(record=record)
transport = mock_transport(requests, rs_first_status=403, rs_first_headers={"WWW-Authenticate": "Bearer"})
async with httpx.AsyncClient(transport=transport, auth=auth) as http:
response = await http.post(f"{RS}/mcp")
assert response.status_code == 403
assert record == []
assert [str(r.url) for r in requests] == [f"{RS}/mcp"]
@pytest.mark.anyio
async def test_token_endpoint_error_surfaces_as_oauth_token_error() -> None:
requests: list[httpx.Request] = []
auth = make_provider()
async with httpx.AsyncClient(transport=mock_transport(requests, asm=asm_body(), token=400), auth=auth) as http:
with pytest.raises(OAuthTokenError, match=r"Token exchange failed \(400\).*invalid_grant"):
await http.post(f"{RS}/mcp")
@pytest.mark.anyio
async def test_client_secret_basic_sends_basic_header_not_body_secret() -> None:
requests: list[httpx.Request] = []
auth = make_provider(token_endpoint_auth_method="client_secret_basic")
async with httpx.AsyncClient(
transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth
) as http:
await http.post(f"{RS}/mcp")
[token_req] = [r for r in requests if r.url.path == "/token"]
assert "client_secret" not in form(token_req)
decoded = base64.b64decode(token_req.headers["Authorization"].removeprefix("Basic ")).decode()
assert decoded == "test-client-id:test-client-secret"
@pytest.mark.anyio
async def test_stored_token_is_reused_without_reauthorizing() -> None:
"""A valid stored token is sent on the first request; on success no ASM or /token is fetched."""
requests: list[httpx.Request] = []
storage = InMemoryStorage(tokens=OAuthToken(access_token="cached", token_type="Bearer", expires_in=3600))
auth = make_provider(storage)
transport = mock_transport(requests, rs_first_status=200)
async with httpx.AsyncClient(transport=transport, auth=auth) as http:
response = await http.post(f"{RS}/mcp")
assert response.status_code == 200
assert [str(r.url) for r in requests] == [f"{RS}/mcp"]
assert requests[0].headers["Authorization"] == "Bearer cached"
@pytest.mark.anyio
async def test_second_401_re_exchanges_without_refetching_asm() -> None:
"""ASM is discovered once; a later 401 mints a fresh assertion against the cached token endpoint."""
requests: list[httpx.Request] = []
record: list[tuple[str, str]] = []
auth = make_provider(record=record)
rs_hits = 0
def handle(request: httpx.Request) -> httpx.Response:
nonlocal rs_hits
requests.append(request)
host, path = request.url.host, request.url.path
if host == "mcp.example.com":
rs_hits += 1
# First and third RS hits draw a 401; second and fourth succeed.
return httpx.Response(401 if rs_hits in (1, 3) else 200)
if host == "auth.example.com" and path == ASM_PATH:
return httpx.Response(200, content=asm_body(), headers={"content-type": "application/json"})
assert host == "auth.example.com" and path == "/token"
return httpx.Response(200, content=token_body(), headers={"content-type": "application/json"})
async with httpx.AsyncClient(transport=httpx.MockTransport(handle), auth=auth) as http:
await http.post(f"{RS}/mcp")
await http.post(f"{RS}/mcp")
asm_gets = [r for r in requests if r.url.path == ASM_PATH]
token_posts = [r for r in requests if r.url.path == "/token"]
assert len(asm_gets) == 1
assert len(token_posts) == 2
assert len(record) == 2
@pytest.mark.anyio
async def test_no_configured_scope_omits_scope_and_backfills_from_request() -> None:
"""With no configured scope and no scope in the token response, the stored token records None."""
requests: list[httpx.Request] = []
storage = InMemoryStorage()
auth = make_provider(storage, scope=None)
async with httpx.AsyncClient(
transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth
) as http:
await http.post(f"{RS}/mcp")
[token_req] = [r for r in requests if r.url.path == "/token"]
assert "scope" not in form(token_req)
assert storage.tokens is not None
assert storage.tokens.scope is None
def test_empty_client_secret_is_rejected() -> None:
async def assertion_provider(audience: str, resource: str) -> str:
raise NotImplementedError
with pytest.raises(ValueError, match="client_secret is required"):
IdentityAssertionOAuthProvider(
server_url=f"{RS}/mcp",
storage=InMemoryStorage(),
client_id="c",
client_secret="",
issuer=ISSUER,
assertion_provider=assertion_provider,
)
def test_empty_issuer_is_rejected() -> None:
async def assertion_provider(audience: str, resource: str) -> str:
raise NotImplementedError
with pytest.raises(ValueError, match="issuer is required"):
IdentityAssertionOAuthProvider(
server_url=f"{RS}/mcp",
storage=InMemoryStorage(),
client_id="c",
client_secret="s",
issuer="",
assertion_provider=assertion_provider,
)
def test_origin_normalizes_default_ports() -> None:
"""`_origin` treats an explicit scheme-default port as equal to the port-less form."""
assert _origin("https://host") == _origin("https://host:443")
assert _origin("http://host") == _origin("http://host:80")
assert _origin("https://host") != _origin("https://host:8443")
assert _origin("https://host") != _origin("https://other")
+135
View File
@@ -0,0 +1,135 @@
from collections.abc import Callable, Generator
from contextlib import asynccontextmanager
from typing import Any
from unittest.mock import patch
import pytest
from mcp_types import JSONRPCNotification, JSONRPCRequest
import mcp.shared.memory
from mcp.client._transport import WriteStream
from mcp.shared.message import SessionMessage
class SpyMemoryObjectSendStream:
def __init__(self, original_stream: WriteStream[SessionMessage]):
self.original_stream = original_stream
self.sent_messages: list[SessionMessage] = []
async def send(self, message: SessionMessage):
self.sent_messages.append(message)
await self.original_stream.send(message)
async def aclose(self):
await self.original_stream.aclose()
async def __aenter__(self):
return self
async def __aexit__(self, *args: Any):
await self.aclose()
class StreamSpyCollection:
def __init__(self, client_spy: SpyMemoryObjectSendStream, server_spy: SpyMemoryObjectSendStream):
self.client = client_spy
self.server = server_spy
def clear(self) -> None:
"""Clear all captured messages."""
self.client.sent_messages.clear()
self.server.sent_messages.clear()
def get_client_requests(self, method: str | None = None) -> list[JSONRPCRequest]:
"""Get client-sent requests, optionally filtered by method."""
return [
req.message
for req in self.client.sent_messages
if isinstance(req.message, JSONRPCRequest) and (method is None or req.message.method == method)
]
def get_server_requests(self, method: str | None = None) -> list[JSONRPCRequest]: # pragma: no cover
"""Get server-sent requests, optionally filtered by method."""
return [ # pragma: no cover
req.message
for req in self.server.sent_messages
if isinstance(req.message, JSONRPCRequest) and (method is None or req.message.method == method)
]
def get_client_notifications(self, method: str | None = None) -> list[JSONRPCNotification]: # pragma: no cover
"""Get client-sent notifications, optionally filtered by method."""
return [
notif.message
for notif in self.client.sent_messages
if isinstance(notif.message, JSONRPCNotification) and (method is None or notif.message.method == method)
]
def get_server_notifications(self, method: str | None = None) -> list[JSONRPCNotification]: # pragma: no cover
"""Get server-sent notifications, optionally filtered by method."""
return [
notif.message
for notif in self.server.sent_messages
if isinstance(notif.message, JSONRPCNotification) and (method is None or notif.message.method == method)
]
@pytest.fixture
def stream_spy() -> Generator[Callable[[], StreamSpyCollection], None, None]:
"""Fixture that provides spies for both client and server write streams.
Example:
```python
async def test_something(stream_spy):
# ... set up server and client ...
spies = stream_spy()
# Run some operation that sends messages
await client.some_operation()
# Check the messages
requests = spies.get_client_requests(method="some/method")
assert len(requests) == 1
# Clear for the next operation
spies.clear()
```
"""
client_spy = None
server_spy = None
# Store references to our spy objects
def capture_spies(c_spy: SpyMemoryObjectSendStream, s_spy: SpyMemoryObjectSendStream):
nonlocal client_spy, server_spy
client_spy = c_spy
server_spy = s_spy
# Create patched version of stream creation
original_create_streams = mcp.shared.memory.create_client_server_memory_streams
@asynccontextmanager
async def patched_create_streams():
async with original_create_streams() as (client_streams, server_streams):
client_read, client_write = client_streams
server_read, server_write = server_streams
# Create spy wrappers
spy_client_write = SpyMemoryObjectSendStream(client_write)
spy_server_write = SpyMemoryObjectSendStream(server_write)
# Capture references for the test to use
capture_spies(spy_client_write, spy_server_write)
yield (client_read, spy_client_write), (server_read, spy_server_write)
# Apply the patch for the duration of the test
# Patch both locations since InMemoryTransport imports it directly
with patch("mcp.shared.memory.create_client_server_memory_streams", patched_create_streams):
with patch("mcp.client._memory.create_client_server_memory_streams", patched_create_streams):
# Return a collection with helper methods
def get_spy_collection() -> StreamSpyCollection:
assert client_spy is not None, "client_spy was not initialized"
assert server_spy is not None, "server_spy was not initialized"
return StreamSpyCollection(client_spy, server_spy)
yield get_spy_collection
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+948
View File
@@ -0,0 +1,948 @@
"""Tests for the unified Client class."""
from __future__ import annotations
import contextvars
from collections.abc import AsyncIterator, Iterator
from contextlib import asynccontextmanager, contextmanager
from unittest.mock import patch
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CallToolResult,
EmptyResult,
GetPromptResult,
ListPromptsResult,
ListResourcesResult,
ListResourceTemplatesResult,
ListToolsResult,
Prompt,
PromptArgument,
PromptMessage,
PromptsCapability,
ReadResourceResult,
Resource,
ResourcesCapability,
ServerCapabilities,
TextContent,
TextResourceContents,
Tool,
ToolsCapability,
)
from mcp_types.version import LATEST_HANDSHAKE_VERSION
from pydantic import FileUrl
from mcp import MCPDeprecationWarning, MCPError
from mcp.client._memory import InMemoryTransport
from mcp.client._transport import TransportStreams
from mcp.client.client import Client
from mcp.client.session import ClientRequestContext
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server, ServerRequestContext
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
from mcp.shared.message import SessionMessage
from tests.interaction._connect import BASE_URL, mounted_app
pytestmark = pytest.mark.anyio
@pytest.fixture
def simple_server() -> Server:
"""Create a simple MCP server for testing."""
async def handle_list_resources(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> ListResourcesResult:
return ListResourcesResult(
resources=[Resource(uri="memory://test", name="Test Resource", description="A test resource")]
)
async def handle_subscribe_resource(ctx: ServerRequestContext, params: types.SubscribeRequestParams) -> EmptyResult:
return EmptyResult()
async def handle_unsubscribe_resource(
ctx: ServerRequestContext, params: types.UnsubscribeRequestParams
) -> EmptyResult:
return EmptyResult()
async def handle_set_logging_level(ctx: ServerRequestContext, params: types.SetLevelRequestParams) -> EmptyResult:
return EmptyResult()
async def handle_completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> types.CompleteResult:
return types.CompleteResult(completion=types.Completion(values=[]))
return Server( # pyright: ignore[reportDeprecated]
name="test_server",
on_list_resources=handle_list_resources,
on_subscribe_resource=handle_subscribe_resource,
on_unsubscribe_resource=handle_unsubscribe_resource,
on_set_logging_level=handle_set_logging_level,
on_completion=handle_completion,
)
@pytest.fixture
def app() -> MCPServer:
"""Create an MCPServer server for testing."""
server = MCPServer("test")
@server.tool()
def greet(name: str) -> str:
"""Greet someone by name."""
return f"Hello, {name}!"
@server.resource("test://resource")
def test_resource() -> str:
"""A test resource."""
return "Test content"
@server.prompt()
def greeting_prompt(name: str) -> str:
"""A greeting prompt."""
return f"Please greet {name} warmly."
return server
async def test_client_is_initialized(app: MCPServer):
"""Test that the client is initialized after entering context."""
async with Client(app, mode="legacy") as client:
assert client.server_capabilities == snapshot(
ServerCapabilities(
experimental={},
prompts=PromptsCapability(list_changed=False),
resources=ResourcesCapability(subscribe=False, list_changed=False),
tools=ToolsCapability(list_changed=False),
)
)
assert client.server_info.name == "test"
async def test_client_exposes_negotiated_protocol_version(app: MCPServer):
"""The negotiated protocol version is readable after initialization."""
async with Client(app, mode="legacy") as client:
assert client.protocol_version == LATEST_HANDSHAKE_VERSION
async def test_client_with_simple_server(simple_server: Server):
"""Test that from_server works with a basic Server instance."""
async with Client(simple_server) as client:
resources = await client.list_resources()
assert resources == snapshot(
ListResourcesResult(
resources=[Resource(name="Test Resource", uri="memory://test", description="A test resource")]
)
)
async def test_client_send_ping(app: MCPServer):
async with Client(app, mode="legacy") as client:
result = await client.send_ping() # pyright: ignore[reportDeprecated]
assert result == snapshot(EmptyResult())
async def test_client_list_tools(app: MCPServer):
async with Client(app) as client:
result = await client.list_tools()
assert result == snapshot(
ListToolsResult(
tools=[
Tool(
name="greet",
description="Greet someone by name.",
input_schema={
"properties": {"name": {"title": "Name", "type": "string"}},
"required": ["name"],
"title": "greetArguments",
"type": "object",
},
output_schema={
"properties": {"result": {"title": "Result", "type": "string"}},
"required": ["result"],
"title": "greetOutput",
"type": "object",
},
)
]
)
)
async def test_client_call_tool(app: MCPServer):
async with Client(app) as client:
result = await client.call_tool("greet", {"name": "World"})
assert result == snapshot(
CallToolResult(
content=[TextContent(text="Hello, World!")],
structured_content={"result": "Hello, World!"},
)
)
async def test_read_resource(app: MCPServer):
"""Test reading a resource."""
async with Client(app) as client:
result = await client.read_resource("test://resource")
assert result == snapshot(
ReadResourceResult(
contents=[TextResourceContents(uri="test://resource", mime_type="text/plain", text="Test content")]
)
)
async def test_read_resource_error_propagates():
"""MCPError raised by a server handler propagates to the client with its code intact."""
async def handle_read_resource(
ctx: ServerRequestContext, params: types.ReadResourceRequestParams
) -> ReadResourceResult:
raise MCPError(code=404, message="no resource with that URI was found")
server = Server("test", on_read_resource=handle_read_resource)
async with Client(server) as client:
with pytest.raises(MCPError) as exc_info:
await client.read_resource("unknown://example")
assert exc_info.value.error.code == 404
async def test_raise_exceptions_propagates_handler_error_on_modern_inproc_path():
"""`raise_exceptions=True` on the modern in-process path: an unmapped handler
exception reaches the client with its original type chained, instead of being
sanitized to an opaque `INTERNAL_ERROR`."""
async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
raise ValueError("boom")
server = Server("test", on_call_tool=handle_call_tool)
async with Client(server, mode="2026-07-28", raise_exceptions=True) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("explode", {})
# The original exception is chained — not swallowed into a generic "Internal server error".
assert isinstance(exc_info.value.__cause__, ValueError)
assert str(exc_info.value.__cause__) == "boom"
async def test_raise_exceptions_false_sanitizes_handler_error_on_modern_inproc_path():
"""`raise_exceptions=False` (the default) on the modern in-process path: an
unmapped handler exception is sanitized to an opaque `INTERNAL_ERROR` so the
in-process path matches the wire path's leak guard."""
async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
raise ValueError("boom")
server = Server("test", on_call_tool=handle_call_tool)
async with Client(server, mode="2026-07-28", raise_exceptions=False) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("explode", {})
assert exc_info.value.error.code == types.INTERNAL_ERROR
assert exc_info.value.error.message == "Internal server error"
assert exc_info.value.__cause__ is None
async def test_modern_inproc_path_refuses_server_initiated_requests():
"""The in-process modern entry enforces the same prohibition as the other
modern entries: a handler's request-scoped server-initiated request is
refused server-side with the no-back-channel contract, instead of the
protocol-forbidden frame being delivered to the client."""
async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
schema = types.ElicitRequestedSchema(type="object", properties={"x": {"type": "string"}})
await ctx.session.elicit_form("question", schema, related_request_id=ctx.request_id)
raise AssertionError("unreachable: elicit_form must refuse") # pragma: no cover
server = Server("test", on_call_tool=handle_call_tool)
async with Client(server, mode="2026-07-28") as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("asker", {})
assert exc_info.value.error.code == types.INVALID_REQUEST
assert "no back-channel" in exc_info.value.error.message
assert "elicitation/create" in exc_info.value.error.message
async def test_get_prompt(app: MCPServer):
"""Test getting a prompt."""
async with Client(app) as client:
result = await client.get_prompt("greeting_prompt", {"name": "Alice"})
assert result == snapshot(
GetPromptResult(
description="A greeting prompt.",
messages=[PromptMessage(role="user", content=TextContent(text="Please greet Alice warmly."))],
)
)
def test_client_session_property_before_enter(app: MCPServer):
"""Test that accessing session before context manager raises RuntimeError."""
client = Client(app)
with pytest.raises(RuntimeError, match="Client must be used within an async context manager"):
client.session
async def test_client_reentry_raises_runtime_error(app: MCPServer):
"""Test that reentering a client raises RuntimeError."""
async with Client(app) as client:
with pytest.raises(RuntimeError, match="Client is already entered"):
await client.__aenter__()
async def test_client_send_progress_notification():
"""Test sending progress notification."""
received_from_client = None
event = anyio.Event()
async def handle_progress(ctx: ServerRequestContext, params: types.ProgressNotificationParams) -> None:
nonlocal received_from_client
received_from_client = {"progress_token": params.progress_token, "progress": params.progress}
event.set()
server = Server(name="test_server", on_progress=handle_progress) # pyright: ignore[reportDeprecated]
with anyio.fail_after(5):
async with Client(server, mode="legacy") as client:
await client.send_progress_notification(progress_token="token123", progress=50.0) # pyright: ignore[reportDeprecated]
await event.wait()
assert received_from_client == snapshot({"progress_token": "token123", "progress": 50.0})
async def test_client_subscribe_resource(simple_server: Server):
async with Client(simple_server, mode="legacy") as client:
with pytest.warns(MCPDeprecationWarning, match="use Client.listen"):
result = await client.subscribe_resource("memory://test") # pyright: ignore[reportDeprecated]
assert result == snapshot(EmptyResult())
async def test_client_unsubscribe_resource(simple_server: Server):
async with Client(simple_server, mode="legacy") as client:
with pytest.warns(MCPDeprecationWarning, match="use Client.listen"):
result = await client.unsubscribe_resource("memory://test") # pyright: ignore[reportDeprecated]
assert result == snapshot(EmptyResult())
async def test_client_set_logging_level(simple_server: Server):
"""Test setting logging level."""
async with Client(simple_server, mode="legacy") as client:
result = await client.set_logging_level("debug") # pyright: ignore[reportDeprecated]
assert result == snapshot(EmptyResult())
async def test_client_list_resources_with_params(app: MCPServer):
"""Test listing resources with params parameter."""
async with Client(app) as client:
result = await client.list_resources()
assert result == snapshot(
ListResourcesResult(
resources=[
Resource(
name="test_resource",
uri="test://resource",
description="A test resource.",
mime_type="text/plain",
)
]
)
)
async def test_client_list_resource_templates(app: MCPServer):
"""Test listing resource templates with params parameter."""
async with Client(app) as client:
result = await client.list_resource_templates()
assert result == snapshot(ListResourceTemplatesResult(resource_templates=[]))
async def test_list_prompts(app: MCPServer):
"""Test listing prompts with params parameter."""
async with Client(app) as client:
result = await client.list_prompts()
assert result == snapshot(
ListPromptsResult(
prompts=[
Prompt(
name="greeting_prompt",
description="A greeting prompt.",
arguments=[PromptArgument(name="name", required=True)],
)
]
)
)
async def test_complete_with_prompt_reference(simple_server: Server):
"""Test getting completions for a prompt argument."""
async with Client(simple_server) as client:
ref = types.PromptReference(type="ref/prompt", name="test_prompt")
result = await client.complete(ref=ref, argument={"name": "arg", "value": "test"})
assert result == snapshot(types.CompleteResult(completion=types.Completion(values=[])))
def test_client_with_url_initializes_streamable_http_transport():
with patch("mcp.client.client.streamable_http_client") as mock:
_ = Client("http://localhost:8000/mcp")
mock.assert_called_once_with("http://localhost:8000/mcp")
async def test_client_uses_transport_directly(app: MCPServer):
transport = InMemoryTransport(app)
async with Client(transport, mode="legacy") as client:
result = await client.call_tool("greet", {"name": "Transport"})
assert result == snapshot(
CallToolResult(
content=[TextContent(text="Hello, Transport!")],
structured_content={"result": "Hello, Transport!"},
)
)
_TEST_CONTEXTVAR = contextvars.ContextVar("test_var", default="initial")
@contextmanager
def _set_test_contextvar(value: str) -> Iterator[None]:
token = _TEST_CONTEXTVAR.set(value)
try:
yield
finally:
_TEST_CONTEXTVAR.reset(token)
async def test_context_propagation():
"""Sender's contextvars.Context is propagated to the server handler."""
server = MCPServer("test")
@server.tool()
async def check_context() -> str:
"""Return the contextvar value visible to the handler."""
return _TEST_CONTEXTVAR.get()
async with Client(server) as client:
with _set_test_contextvar("client_value"):
result = await client.call_tool("check_context", {})
assert result.content[0].text == "client_value", ( # type: ignore[union-attr]
"Server handler did not see the sender's contextvars.Context"
)
async def test_client_auto_mode_probes_discover_then_adopts(simple_server: Server) -> None:
"""`mode='auto'` over an in-process HTTP transport: the `server/discover` probe
reaches the modern entry and the negotiated protocol version is adopted without
an `initialize` handshake."""
with anyio.fail_after(5):
async with (
mounted_app(simple_server) as (http, _),
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto") as client,
):
assert client.protocol_version == "2026-07-28"
assert (await client.list_resources()).resources[0].name == "Test Resource"
@asynccontextmanager
async def _stream_loop_transport(server: Server) -> AsyncIterator[TransportStreams]:
"""A Transport whose far end is `Server.run` over crossed memory streams - the stdio shape, in process."""
async with (
create_client_server_memory_streams() as ((client_read, client_write), (server_read, server_write)),
anyio.create_task_group() as tg,
):
tg.start_soon(server.run, server_read, server_write, server.create_initialization_options())
yield client_read, client_write
tg.cancel_scope.cancel()
async def test_client_auto_mode_negotiates_modern_over_a_stream_loop(simple_server: Server) -> None:
"""`mode='auto'` against a real `Server.run` stream loop: the probe reaches the
dual-era driver, the connection locks modern, and feature requests are served
at 2026-07-28 with no `initialize` handshake."""
with anyio.fail_after(5):
async with Client(_stream_loop_transport(simple_server), mode="auto") as client:
assert client.protocol_version == "2026-07-28"
assert (await client.list_resources()).resources[0].name == "Test Resource"
async def test_client_pinned_modern_mode_works_over_a_stream_loop(simple_server: Server) -> None:
"""A pinned-modern client sends no probe: its first envelope-bearing request
locks the stream-loop connection modern and is served."""
with anyio.fail_after(5):
async with Client(_stream_loop_transport(simple_server), mode="2026-07-28") as client:
assert client.protocol_version == "2026-07-28"
assert (await client.list_resources()).resources[0].name == "Test Resource"
async def test_client_legacy_mode_still_handshakes_over_a_stream_loop(simple_server: Server) -> None:
"""`mode='legacy'` against the dual-era stream loop is byte-identical legacy:
the handshake runs and the session lands at a handshake-era version."""
with anyio.fail_after(5):
async with Client(_stream_loop_transport(simple_server), mode="legacy") as client:
assert client.protocol_version == LATEST_HANDSHAKE_VERSION
assert (await client.list_resources()).resources[0].name == "Test Resource"
async def test_client_auto_mode_recovers_from_a_timed_out_probe_over_a_stream_loop(
simple_server: Server, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A probe that outlives the client's discover timeout still succeeds on the
(slow-starting) server and locks the connection modern; the fallback
handshake's -32022 is modern evidence, so one corrective re-probe completes
the connect instead of stranding `mode='auto'`."""
monkeypatch.setattr("mcp.client.session.DISCOVER_TIMEOUT_SECONDS", 0.05)
c2relay_send, c2relay_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
relay2s_send, relay2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
async def relay() -> None:
# Hold the client's first frame (the probe) until its second frame (the
# post-timeout initialize) arrives - the deterministic stand-in for a
# server too slow to answer before the client's discover timeout.
held: SessionMessage | Exception | None = None
first = True
async for item in c2relay_recv:
if first:
held, first = item, False
continue
if held is not None:
await relay2s_send.send(held)
held = None
await relay2s_send.send(item)
@asynccontextmanager
async def transport() -> AsyncIterator[TransportStreams]:
async with c2relay_send, c2relay_recv, relay2s_send, relay2s_recv, s2c_send, s2c_recv:
async with anyio.create_task_group() as tg:
tg.start_soon(simple_server.run, relay2s_recv, s2c_send, simple_server.create_initialization_options())
tg.start_soon(relay)
yield s2c_recv, c2relay_send
tg.cancel_scope.cancel()
with anyio.fail_after(10):
async with Client(transport(), mode="auto") as client:
assert client.protocol_version == "2026-07-28"
assert (await client.list_resources()).resources[0].name == "Test Resource"
@pytest.mark.parametrize("code", [types.METHOD_NOT_FOUND, types.REQUEST_TIMEOUT, types.INTERNAL_ERROR])
async def test_client_auto_mode_falls_back_to_initialize_on_legacy_signal(code: int) -> None:
"""`mode='auto'`: any JSON-RPC error from `server/discover` makes
`Client.__aenter__` run the legacy `initialize()` handshake and land at a
handshake-era protocol version. The denylist policy treats every server-sent
rpc-error as "not modern" — including INTERNAL_ERROR, since a legacy server
may crash on the unknown method before reaching its router. A real `Server`
always implements `server/discover`, so the server side is hand-played."""
methods_seen: list[str] = []
async def scripted_server(streams: MessageStream) -> None:
server_read, server_write = streams
async for message in server_read:
assert isinstance(message, SessionMessage)
frame = message.message
assert isinstance(frame, types.JSONRPCRequest | types.JSONRPCNotification)
methods_seen.append(frame.method)
if isinstance(frame, types.JSONRPCNotification):
continue
if frame.method == "server/discover":
error = types.ErrorData(code=code, message="nope")
await server_write.send(SessionMessage(types.JSONRPCError(jsonrpc="2.0", id=frame.id, error=error)))
elif frame.method == "initialize": # pragma: no branch
result = types.InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=types.Implementation(name="legacy-only", version="0.0.1"),
)
await server_write.send(
SessionMessage(
types.JSONRPCResponse(
jsonrpc="2.0",
id=frame.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
@asynccontextmanager
async def scripted_transport() -> AsyncIterator[TransportStreams]:
async with (
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
anyio.create_task_group() as tg,
):
tg.start_soon(scripted_server, server_streams)
yield client_read, client_write
tg.cancel_scope.cancel()
with anyio.fail_after(5):
async with Client(scripted_transport(), mode="auto") as client:
assert client.protocol_version == LATEST_HANDSHAKE_VERSION
assert client.server_info.name == "legacy-only"
assert methods_seen == ["server/discover", "initialize", "notifications/initialized"]
@pytest.mark.anyio
async def test_modern_list_tools_drops_tools_with_invalid_x_mcp_header_but_legacy_does_not() -> None:
"""At 2026-07-28 the spec requires clients to exclude tools whose `x-mcp-header`
annotation is malformed; handshake-era sessions surface them unchanged. Two
tools are advertised — one valid, one with a non-RFC-9110-token header name —
and the modern client sees only the valid one."""
valid = types.Tool(
name="ok",
input_schema={"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "Region"}}},
)
bad = types.Tool(
name="dropme",
input_schema={"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}},
)
async def on_list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[valid, bad])
server = Server("test", on_list_tools=on_list_tools)
with anyio.fail_after(5):
async with Client(server) as client:
result = await client.list_tools()
assert [t.name for t in result.tools] == ["ok"]
async with Client(server, mode="legacy") as client:
result = await client.list_tools()
assert [t.name for t in result.tools] == ["ok", "dropme"]
_RETIRED_TOOL = Tool(
name="retired",
input_schema={"type": "object", "properties": {"region": {"type": "string", "x-mcp-header": "Region"}}},
output_schema={"type": "object"},
)
_SURVIVOR_TOOL = Tool(name="survivor", input_schema={"type": "object"})
def _scripted_listing_server(listings: list[ListToolsResult]) -> Server:
"""Serves the given listings in order, one per tools/list request."""
async def on_list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return listings.pop(0)
return Server("test", on_list_tools=on_list_tools)
async def test_a_complete_listing_prunes_per_tool_state_for_tools_it_no_longer_contains() -> None:
"""SDK-defined: a complete (uncursored, cursorless) listing is the full tool universe, so the
header map and output schema derived from an earlier listing of a now-absent tool are dropped."""
server = _scripted_listing_server(
[
ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]),
ListToolsResult(tools=[_SURVIVOR_TOOL]),
]
)
with anyio.fail_after(5):
async with Client(server) as client:
await client.session.list_tools()
assert set(client.session._x_mcp_header_maps) == {"retired", "survivor"}
assert set(client.session._tool_output_schemas) == {"retired", "survivor"}
await client.session.list_tools()
assert set(client.session._x_mcp_header_maps) == {"survivor"}
assert set(client.session._tool_output_schemas) == {"survivor"}
async def test_a_complete_listing_prunes_output_schemas_on_a_legacy_session_too() -> None:
"""SDK-defined: the prune is era-independent -- legacy sessions cache output schemas the same
way (their header-map dict just stays empty, since the x-mcp-header filter is 2026-only)."""
server = _scripted_listing_server(
[
ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]),
ListToolsResult(tools=[_SURVIVOR_TOOL]),
]
)
with anyio.fail_after(5):
async with Client(server, mode="legacy") as client:
await client.session.list_tools()
assert set(client.session._tool_output_schemas) == {"retired", "survivor"}
assert client.session._x_mcp_header_maps == {}
await client.session.list_tools()
assert set(client.session._tool_output_schemas) == {"survivor"}
async def test_a_listing_with_a_next_cursor_prunes_no_per_tool_state() -> None:
"""SDK-defined: a first page carrying next_cursor is not the full universe -- state for tools
expected on later pages must survive it."""
server = _scripted_listing_server(
[
ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]),
ListToolsResult(tools=[_SURVIVOR_TOOL], next_cursor="2"),
]
)
with anyio.fail_after(5):
async with Client(server) as client:
await client.session.list_tools()
await client.session.list_tools()
assert set(client.session._x_mcp_header_maps) == {"retired", "survivor"}
assert set(client.session._tool_output_schemas) == {"retired", "survivor"}
async def test_a_cursor_page_fetch_prunes_no_per_tool_state() -> None:
"""SDK-defined: a continuation page is partial even when it ends the pagination (no
next_cursor) -- only an uncursored single-page listing prunes."""
server = _scripted_listing_server(
[
ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]),
ListToolsResult(tools=[_SURVIVOR_TOOL]),
]
)
with anyio.fail_after(5):
async with Client(server) as client:
await client.session.list_tools()
await client.session.list_tools(params=types.PaginatedRequestParams(cursor="2"))
assert set(client.session._x_mcp_header_maps) == {"retired", "survivor"}
assert set(client.session._tool_output_schemas) == {"retired", "survivor"}
def test_client_rejects_handshake_era_mode_at_construction() -> None:
"""A handshake-era protocol-version string passed as `mode=` is rejected by
`__post_init__` with a hint to use `mode='legacy'` — the version-pin path is
modern-only."""
server = MCPServer("test")
with pytest.raises(ValueError, match=r"handshake-era version; use mode='legacy'"):
Client(server, mode="2025-06-18")
with pytest.raises(ValueError, match=r"mode must be 'legacy', 'auto', or one of"):
Client(server, mode="not-a-version")
# ── SEP-2322 multi-round-trip auto-loop ────────────────────────────────────────
_NAME_SCHEMA = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}
def _name_elicitation(message: str = "What is your name?") -> types.ElicitRequest:
return types.ElicitRequest(params=types.ElicitRequestFormParams(message=message, requested_schema=_NAME_SCHEMA))
async def test_call_tool_auto_loop_dispatches_elicitation_then_returns_final_result() -> None:
"""When the server returns `InputRequiredResult` carrying an elicitation,
`Client.call_tool` routes it to `elicitation_callback` and retries
automatically — the caller sees only the terminal `CallToolResult`."""
server = MCPServer("test")
@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()})
callback_params: list[types.ElicitRequestParams] = []
async def elicitation_callback(
context: ClientRequestContext, params: types.ElicitRequestParams
) -> types.ElicitResult | types.ErrorData:
callback_params.append(params)
assert context.request_id == "user_name" # the inputRequests key is the request id
return types.ElicitResult(action="accept", content={"name": "Ada"})
with anyio.fail_after(5):
async with Client(server, elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("greet")
assert result == snapshot(
CallToolResult(content=[TextContent(text="Hello, Ada!")], structured_content={"result": "Hello, Ada!"})
)
assert len(callback_params) == 1
assert isinstance(callback_params[0], types.ElicitRequestFormParams)
assert callback_params[0].message == "What is your name?"
assert callback_params[0].requested_schema == _NAME_SCHEMA
async def test_call_tool_auto_loop_dispatches_sampling_then_returns_final_result() -> None:
"""`InputRequiredResult` with an embedded `CreateMessageRequest` is routed
to `sampling_callback` and the call retried with the model's reply."""
server = MCPServer("test")
@server.tool()
async def ask(ctx: Context) -> str | types.InputRequiredResult:
responses = ctx.input_responses
if responses and "q" in responses:
answer = responses["q"]
assert isinstance(answer, types.CreateMessageResult)
assert answer.content.type == "text"
return f"Model said: {answer.content.text}"
return types.InputRequiredResult(
input_requests={
"q": types.CreateMessageRequest(
params=types.CreateMessageRequestParams(
messages=[types.SamplingMessage(role="user", content=TextContent(text="Capital of France?"))],
max_tokens=10,
)
)
}
)
callback_params: list[types.CreateMessageRequestParams] = []
async def sampling_callback(
context: ClientRequestContext, params: types.CreateMessageRequestParams
) -> types.CreateMessageResult | types.ErrorData:
callback_params.append(params)
return types.CreateMessageResult(role="assistant", content=TextContent(text="Paris"), model="echo")
with anyio.fail_after(5):
async with Client(server, sampling_callback=sampling_callback) as client:
result = await client.call_tool("ask")
assert result == snapshot(
CallToolResult(
content=[TextContent(text="Model said: Paris")], structured_content={"result": "Model said: Paris"}
)
)
assert len(callback_params) == 1
assert callback_params[0].messages[0].content == TextContent(text="Capital of France?")
async def test_call_tool_auto_loop_dispatches_list_roots_then_returns_final_result() -> None:
"""`InputRequiredResult` with an embedded `ListRootsRequest` is routed to
`list_roots_callback` and the call retried with the returned roots."""
server = MCPServer("test")
@server.tool()
async def count_roots(ctx: Context) -> str | types.InputRequiredResult:
responses = ctx.input_responses
if responses and "roots" in responses:
answer = responses["roots"]
assert isinstance(answer, types.ListRootsResult)
return f"Client exposed {len(answer.roots)} root(s)."
return types.InputRequiredResult(input_requests={"roots": types.ListRootsRequest()})
callback_called: list[ClientRequestContext] = []
async def list_roots_callback(context: ClientRequestContext) -> types.ListRootsResult | types.ErrorData:
callback_called.append(context)
return types.ListRootsResult(roots=[types.Root(uri=FileUrl("file:///workspace"))])
with anyio.fail_after(5):
async with Client(server, list_roots_callback=list_roots_callback) as client:
result = await client.call_tool("count_roots")
assert result == snapshot(
CallToolResult(
content=[TextContent(text="Client exposed 1 root(s).")],
structured_content={"result": "Client exposed 1 root(s)."},
)
)
assert len(callback_called) == 1
assert callback_called[0].request_id == "roots"
async def test_call_tool_auto_loop_round_trips_evolving_request_state_across_three_rounds() -> None:
"""A three-round flow where each `InputRequiredResult.request_state`
encodes the round number: the driver echoes it back byte-exact, the server
advances per round, and the elicitation callback runs once per round."""
server = MCPServer("test")
@server.tool()
async def multi(ctx: Context) -> str | types.InputRequiredResult:
# Round number is the integer the server stashed in `request_state` last leg.
round_num = int(ctx.request_state) if ctx.request_state else 0
if round_num == 3:
return "done after 3 rounds"
next_round = round_num + 1
return types.InputRequiredResult(
input_requests={f"step{next_round}": _name_elicitation(f"Round {next_round}?")},
request_state=str(next_round),
)
messages: list[str] = []
async def elicitation_callback(
context: ClientRequestContext, params: types.ElicitRequestParams
) -> types.ElicitResult | types.ErrorData:
assert isinstance(params, types.ElicitRequestFormParams)
messages.append(params.message)
return types.ElicitResult(action="accept", content={"name": "x"})
with anyio.fail_after(5):
async with Client(server, elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("multi")
assert result.content == [TextContent(text="done after 3 rounds")]
assert messages == ["Round 1?", "Round 2?", "Round 3?"]
async def test_call_tool_auto_loop_raises_mcp_error_when_no_callback_registered() -> None:
"""SDK-defined: with no `elicitation_callback`, the default returns
`ErrorData(INVALID_REQUEST, ...)` and the driver raises it as `MCPError`
rather than retrying."""
server = MCPServer("test")
@server.tool()
async def needs_input(ctx: Context) -> str | types.InputRequiredResult:
if ctx.input_responses:
raise NotImplementedError # unreachable: client errors before retrying
return types.InputRequiredResult(input_requests={"ask": _name_elicitation()})
async with Client(server) as client:
with anyio.fail_after(5), pytest.raises(MCPError) as exc:
await client.call_tool("needs_input")
assert exc.value.error.code == types.INVALID_REQUEST
async def test_get_prompt_auto_loop_resolves_input_required_via_callbacks() -> None:
"""`Client.get_prompt` runs the same driver as `call_tool`: an
`InputRequiredResult` from `prompts/get` is fulfilled and retried."""
async def handler(
ctx: ServerRequestContext, params: types.GetPromptRequestParams
) -> types.GetPromptResult | types.InputRequiredResult:
assert params.name == "summary"
if params.input_responses and "ask" in params.input_responses:
return GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="ok"))])
return types.InputRequiredResult(input_requests={"ask": _name_elicitation()})
server = Server("test")
server.add_request_handler("prompts/get", types.GetPromptRequestParams, handler)
async def elicitation_callback(
context: ClientRequestContext, params: types.ElicitRequestParams
) -> types.ElicitResult | types.ErrorData:
return types.ElicitResult(action="accept", content={"name": "x"})
with anyio.fail_after(5):
async with Client(server, mode="2026-07-28", elicitation_callback=elicitation_callback) as client:
result = await client.get_prompt("summary")
assert result == snapshot(GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="ok"))]))
async def test_read_resource_auto_loop_resolves_input_required_via_callbacks() -> None:
"""`Client.read_resource` runs the same driver as `call_tool`: an
`InputRequiredResult` from `resources/read` is fulfilled and retried."""
async def handler(
ctx: ServerRequestContext, params: types.ReadResourceRequestParams
) -> types.ReadResourceResult | types.InputRequiredResult:
assert params.uri == "memory://gated"
if params.input_responses and "ask" in params.input_responses:
return ReadResourceResult(contents=[TextResourceContents(uri="memory://gated", text="unlocked")])
return types.InputRequiredResult(input_requests={"ask": _name_elicitation()})
server = Server("test")
server.add_request_handler("resources/read", types.ReadResourceRequestParams, handler)
async def elicitation_callback(
context: ClientRequestContext, params: types.ElicitRequestParams
) -> types.ElicitResult | types.ErrorData:
return types.ElicitResult(action="accept", content={"name": "x"})
with anyio.fail_after(5):
async with Client(server, mode="2026-07-28", elicitation_callback=elicitation_callback) as client:
result = await client.read_resource("memory://gated")
assert result == snapshot(
ReadResourceResult(contents=[TextResourceContents(uri="memory://gated", text="unlocked")])
)
File diff suppressed because it is too large Load Diff
+573
View File
@@ -0,0 +1,573 @@
"""`Client` + `ClientExtension` integration: extension declarations fold into the session at
construction, and `call_tool` drives claim resolvers transparently against real `MCPServer`s.
"""
import logging
from collections.abc import Awaitable, Callable, Sequence
from typing import Any, Literal, cast
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, Result, TextContent
from mcp_types.version import LATEST_MODERN_VERSION
from pydantic import BaseModel
from typing_extensions import assert_type
from mcp.client import ClaimContext, ClientExtension, NotificationBinding, ResultClaim, advertise
from mcp.client.client import Client
from mcp.client.session import ClientRequestContext, _CallToolResultAdapter
from mcp.server import Server, ServerRequestContext
from mcp.server.context import CallNext, HandlerResult
from mcp.server.extension import Extension
from mcp.server.mcpserver import Context, MCPServer
pytestmark = pytest.mark.anyio
_VOUCHER_EXT = "com.example/voucher"
_RIVAL_EXT = "com.example/rival"
_NAME_SCHEMA = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}
def _name_elicitation() -> types.ElicitRequest:
return types.ElicitRequest(
params=types.ElicitRequestFormParams(message="What is your name?", requested_schema=_NAME_SCHEMA)
)
class VoucherResult(Result):
"""The claimed `tools/call` shape, tagged `voucher`, carrying a vendor top-level field."""
result_type: Literal["voucher"] = "voucher"
voucher_code: str | None = None
_Resolver = Callable[[VoucherResult, ClaimContext], Awaitable[CallToolResult]]
class _VoucherExtension(ClientExtension):
"""Client half: claims the `voucher` tag with the supplied resolver."""
identifier = _VOUCHER_EXT
def __init__(self, resolve: _Resolver) -> None:
self._resolve = resolve
def claims(self) -> Sequence[ResultClaim[Any]]:
return [ResultClaim(result_type="voucher", model=VoucherResult, resolve=self._resolve)]
class _VoucherIssuer(Extension):
"""Server half: rewrites every `tools/call` result into the vendor-claimed shape."""
identifier = _VOUCHER_EXT
async def intercept_tool_call(
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
) -> HandlerResult:
return {"resultType": "voucher", "voucherCode": "v-42"}
class _TwoRoundVoucherIssuer(Extension):
"""Server half: demands input on the first round, then issues the claimed shape."""
identifier = _VOUCHER_EXT
async def intercept_tool_call(
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
) -> HandlerResult:
if params.input_responses is None:
return types.InputRequiredResult(input_requests={"user_name": _name_elicitation()})
return {"resultType": "voucher", "voucherCode": "after-input"}
def _voucher_server(issuer: Extension | None = None) -> MCPServer:
"""An `MCPServer` whose `issue` tool the server extension rewrites into the claimed shape."""
server = MCPServer("vouchers", extensions=[issuer if issuer is not None else _VoucherIssuer()])
@server.tool()
def issue() -> CallToolResult:
"""Issue a voucher."""
raise NotImplementedError # the server extension short-circuits before the tool runs
return server
def _structured_voucher_server() -> MCPServer:
"""Like `_voucher_server`, but `issue` declares an output schema (`-> str`)."""
server = MCPServer("vouchers", extensions=[_VoucherIssuer()])
@server.tool()
def issue() -> str:
"""Issue a voucher."""
raise NotImplementedError # the server extension short-circuits before the tool runs
return server
def _add_server() -> MCPServer:
"""A plain claim-less server with one ordinary tool."""
server = MCPServer("plain")
@server.tool()
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
return server
# Construction-time validation
class _CouponResult(Result):
result_type: Literal["coupon"] = "coupon"
async def _unreachable_coupon_resolve(claimed: _CouponResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError # the wrong resolver for a voucher; must never run
class _CouponExtension(ClientExtension):
identifier = "com.example/coupons"
def claims(self) -> Sequence[ResultClaim[Any]]:
return [ResultClaim(result_type="coupon", model=_CouponResult, resolve=_unreachable_coupon_resolve)]
class _SelfConflictingClaims(ClientExtension):
identifier = "com.example/twice"
def claims(self) -> Sequence[ResultClaim[Any]]:
return [
ResultClaim(result_type="twice", model=_TwiceResult, resolve=_unreachable_twice_resolve),
ResultClaim(result_type="twice", model=_TwiceResult, resolve=_unreachable_twice_resolve),
]
class _TwiceResult(Result):
result_type: Literal["twice"] = "twice"
async def _unreachable_twice_resolve(claimed: _TwiceResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError
def test_mapping_extensions_get_the_migration_error() -> None:
"""SDK-defined: the replaced dict form fails with a message naming the new shape."""
with pytest.raises(TypeError) as exc_info:
Client(_add_server(), extensions=cast("Sequence[ClientExtension]", {"com.example/ui": {}}))
assert str(exc_info.value) == snapshot(
"extensions= takes a sequence of ClientExtension instances. The mapping form was "
"replaced: use advertise(identifier, settings) for advertise-only entries"
)
def test_one_extension_claiming_a_tag_twice_reads_as_one_owner() -> None:
"""SDK-defined: a self-conflict names the one extension once, not as a pair."""
with pytest.raises(ValueError) as exc_info:
Client(_add_server(), extensions=[_SelfConflictingClaims()])
assert str(exc_info.value) == snapshot(
"extension 'com.example/twice' claims resultType 'twice'; a wire tag can have only one resolver"
)
def test_bare_extension_instance_is_rejected_with_the_fix_named() -> None:
"""SDK-defined: an instance whose class never set `identifier` fails construction naming the type and the fix."""
with pytest.raises(ValueError) as exc_info:
Client(_add_server(), extensions=[ClientExtension()])
assert str(exc_info.value) == snapshot(
"ClientExtension has no `identifier`; a ClientExtension must set the `identifier` "
"class attribute (or assign one in `__init__`) before it can be used"
)
class _SelfAssignedBadId(ClientExtension):
"""Assigns a malformed identifier in `__init__`, invisible at class definition."""
def __init__(self) -> None:
self.identifier = "not-prefixed"
def test_invalid_per_instance_identifier_raises_the_validators_error() -> None:
"""SDK-defined: per-instance identifiers are validated when the Client consumes the extension."""
with pytest.raises(TypeError) as exc_info:
Client(_add_server(), extensions=[_SelfAssignedBadId()])
assert str(exc_info.value) == snapshot(
"_SelfAssignedBadId.identifier must be a `vendor-prefix/name` string "
"(reverse-DNS prefix required), got 'not-prefixed'"
)
def test_duplicate_extension_identifiers_are_rejected_naming_the_identifier() -> None:
"""SDK-defined: one identifier cannot appear twice across the extensions sequence."""
with pytest.raises(ValueError) as exc_info:
Client(_add_server(), extensions=[advertise(_VOUCHER_EXT), advertise(_VOUCHER_EXT, {"a": 1})])
assert str(exc_info.value) == snapshot("extension identifier 'com.example/voucher' is passed more than once")
async def _unreachable_resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError
class _RivalVoucherExtension(ClientExtension):
identifier = _RIVAL_EXT
def claims(self) -> Sequence[ResultClaim[Any]]:
return [ResultClaim(result_type="voucher", model=VoucherResult, resolve=_unreachable_resolve)]
def test_conflicting_claims_across_extensions_name_both_owners() -> None:
"""SDK-defined: two extensions claiming the same tag fail at construction with both owners named."""
with pytest.raises(ValueError) as exc_info:
Client(_add_server(), extensions=[_VoucherExtension(_unreachable_resolve), _RivalVoucherExtension()])
assert str(exc_info.value) == snapshot(
"extensions 'com.example/voucher' and 'com.example/rival' both claim resultType "
"'voucher'; a wire tag can have only one resolver"
)
class _EventParams(BaseModel):
seq: int
async def _unreachable_handler(params: _EventParams) -> None:
raise NotImplementedError
class _ObserverA(ClientExtension):
identifier = "com.example/observer-a"
def notifications(self) -> Sequence[NotificationBinding[Any]]:
return [
NotificationBinding(
method="notifications/vendor/event", params_type=_EventParams, handler=_unreachable_handler
)
]
class _ObserverB(ClientExtension):
identifier = "com.example/observer-b"
def notifications(self) -> Sequence[NotificationBinding[Any]]:
return [
NotificationBinding(
method="notifications/vendor/event", params_type=_EventParams, handler=_unreachable_handler
)
]
def test_conflicting_notification_bindings_name_both_owners() -> None:
"""SDK-defined: two extensions binding the same notification method fail with both owners named."""
with pytest.raises(ValueError) as exc_info:
Client(_add_server(), extensions=[_ObserverA(), _ObserverB()])
assert str(exc_info.value) == snapshot(
"extensions 'com.example/observer-a' and 'com.example/observer-b' both bind "
"notification method 'notifications/vendor/event'; a method can have only one observer"
)
# settings() consumption
class _CountedResult(Result):
result_type: Literal["counted"] = "counted"
async def _unreachable_counted_resolve(claimed: _CountedResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError
class _CountingSettings(ClientExtension):
identifier = "com.example/counted"
def __init__(self) -> None:
self.reads = 0
self.claims_reads = 0
self.notifications_reads = 0
def settings(self) -> dict[str, Any]:
self.reads += 1
return {"read": self.reads}
def claims(self) -> Sequence[ResultClaim[Any]]:
self.claims_reads += 1
return [ResultClaim(result_type="counted", model=_CountedResult, resolve=_unreachable_counted_resolve)]
def notifications(self) -> Sequence[NotificationBinding[Any]]:
self.notifications_reads += 1
return [
NotificationBinding(method="notifications/counted", params_type=_EventParams, handler=_unreachable_handler)
]
async def test_declarations_are_read_exactly_once_at_construction() -> None:
"""SDK-defined: each declaration method is read exactly once, at Client construction, never again."""
extension = _CountingSettings()
client = Client(_add_server(), extensions=[extension])
assert (extension.reads, extension.claims_reads, extension.notifications_reads) == (1, 1, 1)
with anyio.fail_after(5):
async with client:
await client.call_tool("add", {"a": 1, "b": 2})
await client.call_tool("add", {"a": 3, "b": 4})
assert (extension.reads, extension.claims_reads, extension.notifications_reads) == (1, 1, 1)
async def test_settings_dict_is_held_by_reference_not_copied() -> None:
"""SDK-defined: the settings dict is held by reference, so mutating it before connect changes the ad."""
observed: list[dict[str, dict[str, Any]] | None] = []
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "probe"
assert ctx.session.client_params is not None
observed.append(ctx.session.client_params.capabilities.extensions)
return CallToolResult(content=[])
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})])
server = Server("probe", on_call_tool=call_tool, on_list_tools=list_tools)
settings = {"tier": "bronze"}
client = Client(server, extensions=[advertise("com.example/loyalty", settings)])
settings["tier"] = "gold"
with anyio.fail_after(5):
async with client:
await client.call_tool("probe", {})
assert observed == [{"com.example/loyalty": {"tier": "gold"}}]
# extensions=None stays byte-identical
@pytest.mark.parametrize("extensions", [None, ()], ids=["none", "empty"])
async def test_no_extensions_keeps_tools_call_parsing_byte_identical(
extensions: Sequence[ClientExtension] | None,
) -> None:
"""SDK-defined: `extensions=None` and an empty sequence leave the session exactly as a claim-less client's."""
with anyio.fail_after(5):
async with Client(_add_server(), extensions=extensions) as client:
assert client.session._call_tool_adapter is _CallToolResultAdapter
result = await client.call_tool("add", {"a": 1, "b": 2})
assert result.structured_content == {"result": 3}
# The transparent claim path
async def test_claimed_result_resolves_transparently_to_the_resolvers_result() -> None:
"""A claimed shape never surfaces: the resolver gets the parsed model and `call_tool` returns its product."""
received: list[VoucherResult] = []
produced: list[CallToolResult] = []
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
received.append(claimed)
product = CallToolResult(content=[TextContent(text=f"honored {claimed.voucher_code}")])
produced.append(product)
return product
with anyio.fail_after(5):
async with Client(_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client:
result = await client.call_tool("issue", {})
assert_type(result, CallToolResult)
assert [claimed.voucher_code for claimed in received] == ["v-42"]
assert result is produced[0]
assert result.content == [TextContent(text="honored v-42")]
async def test_claimed_shape_routes_to_its_owning_extensions_resolver() -> None:
"""With two claim-bearing extensions registered, the parsed shape runs its owner's resolver only."""
received: list[VoucherResult] = []
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
received.append(claimed)
return CallToolResult(content=[TextContent(text="routed")])
extensions = [_CouponExtension(), _VoucherExtension(resolve)]
with anyio.fail_after(5):
async with Client(_voucher_server(), extensions=extensions) as client:
result = await client.call_tool("issue", {})
assert [claimed.voucher_code for claimed in received] == ["v-42"]
assert result.content == [TextContent(text="routed")]
async def test_resolver_product_gets_the_direct_paths_output_schema_revalidation() -> None:
"""The resolver's product is revalidated against the tool's output schema exactly like a direct result."""
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
return CallToolResult(content=[TextContent(text="unstructured")])
async with Client(_structured_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client:
with anyio.fail_after(5), pytest.raises(RuntimeError) as exc_info:
await client.call_tool("issue", {})
assert str(exc_info.value) == snapshot("Tool issue has an output schema but did not return structured content")
async def test_resolver_error_result_is_returned_not_raised() -> None:
"""An `isError` resolver product skips output-schema revalidation and comes back as-is."""
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
return CallToolResult(content=[TextContent(text="voucher printer on fire")], is_error=True)
with anyio.fail_after(5):
async with Client(_structured_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client:
result = await client.call_tool("issue", {})
assert result.is_error
assert result.content == [TextContent(text="voucher printer on fire")]
async def test_resolver_receives_the_calls_claim_context() -> None:
"""`ClaimContext` carries the client's own session object, the tool name, and the per-call read timeout."""
contexts: list[ClaimContext] = []
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
contexts.append(ctx)
return CallToolResult(content=[])
with anyio.fail_after(5):
async with Client(_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client:
await client.call_tool("issue", {}, read_timeout_seconds=7.0)
[ctx] = contexts
assert ctx.session is client.session
assert ctx.tool_name == "issue"
assert ctx.read_timeout_seconds == 7.0
class _VoucherRefused(Exception):
"""Extension-owned error vocabulary."""
async def test_resolver_exception_propagates_untouched() -> None:
"""A resolver exception reaches the `call_tool` caller as the very object raised, unwrapped."""
refusal = _VoucherRefused("the voucher is refused")
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
raise refusal
async with Client(_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client:
with anyio.fail_after(5), pytest.raises(_VoucherRefused) as exc_info:
await client.call_tool("issue", {})
assert exc_info.value is refusal
# Unclaimed results with extensions present
async def test_unclaimed_result_flows_through_unchanged_with_extensions_present() -> None:
"""An ordinary `CallToolResult` is untouched by the claim machinery; the resolver never runs."""
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError # this server never produces a claimed shape
with anyio.fail_after(5):
async with Client(_add_server(), extensions=[_VoucherExtension(resolve)]) as client:
result = await client.call_tool("add", {"a": 1, "b": 2})
assert result.structured_content == {"result": 3}
async def test_input_required_then_plain_result_keeps_the_auto_loop_working() -> None:
"""With a claim-bearing extension present, the input_required auto loop on an unclaimed tool is unchanged."""
server = MCPServer("mrtr")
@server.tool()
async def greet(ctx: Context) -> str | types.InputRequiredResult:
responses = ctx.input_responses
if responses and "user_name" in responses:
answer = responses["user_name"]
assert isinstance(answer, types.ElicitResult)
assert answer.content is not None
return f"Hello, {answer.content['name']}!"
return types.InputRequiredResult(input_requests={"user_name": _name_elicitation()})
async def elicitation_callback(
context: ClientRequestContext, params: types.ElicitRequestParams
) -> types.ElicitResult | types.ErrorData:
return types.ElicitResult(action="accept", content={"name": "Ada"})
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError # this server never produces a claimed shape
with anyio.fail_after(5):
async with Client(
server, elicitation_callback=elicitation_callback, extensions=[_VoucherExtension(resolve)]
) as client:
result = await client.call_tool("greet")
assert result.content == [TextContent(text="Hello, Ada!")]
# The multi-round-trip + claimed interplay
async def test_input_required_then_claimed_result_on_retry_resolves_transparently() -> None:
"""A call that demands input first and returns a claimed shape on the retry still resolves transparently."""
prompted: list[str] = []
received: list[VoucherResult] = []
async def elicitation_callback(
context: ClientRequestContext, params: types.ElicitRequestParams
) -> types.ElicitResult | types.ErrorData:
assert isinstance(params, types.ElicitRequestFormParams)
prompted.append(params.message)
return types.ElicitResult(action="accept", content={"name": "Ada"})
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
received.append(claimed)
return CallToolResult(content=[TextContent(text=f"honored {claimed.voucher_code}")])
server = _voucher_server(issuer=_TwoRoundVoucherIssuer())
with anyio.fail_after(5):
async with Client(
server, elicitation_callback=elicitation_callback, extensions=[_VoucherExtension(resolve)]
) as client:
result = await client.call_tool("issue", {})
assert prompted == ["What is your name?"]
assert [claimed.voucher_code for claimed in received] == ["after-input"]
assert result.content == [TextContent(text="honored after-input")]
# Notification bindings fold into the session
class _CoreMethodObserver(ClientExtension):
"""Binds a method the modern core tables already define."""
identifier = "com.example/observer"
def notifications(self) -> Sequence[NotificationBinding[Any]]:
return [
NotificationBinding(method="notifications/message", params_type=_EventParams, handler=_unreachable_handler)
]
async def test_notification_bindings_fold_into_the_session(caplog: pytest.LogCaptureFixture) -> None:
"""The Client threads extension bindings into its session; a core-known binding draws the one-time warning."""
with caplog.at_level(logging.WARNING, logger="client"):
async with Client(_add_server(), extensions=[_CoreMethodObserver()]):
pass
expected = f"notification binding for 'notifications/message' will never fire at {LATEST_MODERN_VERSION}"
assert caplog.text.count(expected) == 1
+379
View File
@@ -0,0 +1,379 @@
"""Construction-time tests for `mcp.client.extension`; no session is ever opened."""
from dataclasses import FrozenInstanceError
from typing import Any, Literal, cast
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, InputRequiredResult, Result
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from pydantic import AliasChoices, AliasPath, BaseModel, Field
from pydantic.fields import FieldInfo
from mcp.client.extension import (
ClaimContext,
ClientExtension,
NotificationBinding,
ResultClaim,
_wire_keys,
advertise,
)
class _TaskResult(Result):
result_type: Literal["task"] = "task"
task_id: str = "t-1"
class _UntaggedResult(Result):
"""No `result_type` field at all."""
class _PlainStringTagResult(Result):
result_type: str = "task"
class _OtherTagResult(Result):
result_type: Literal["other"] = "other"
class _ClaimedCallToolResult(CallToolResult):
"""A core-result subclass; rejected as a claim model regardless of its tag."""
class _ClaimedInputRequiredResult(InputRequiredResult):
"""A core-result subclass; rejected as a claim model regardless of its tag."""
async def _resolve(result: Result, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError
def _claim(model: type[Result] = _TaskResult, **kwargs: Any) -> ResultClaim[Result]:
return ResultClaim(result_type="task", model=model, resolve=_resolve, **kwargs)
def test_claim_with_literal_discriminated_model_constructs() -> None:
"""SDK-defined: a model tagged with the claimed Literal constructs, defaulting to `tools/call` everywhere."""
claim = ResultClaim(result_type="task", model=_TaskResult, resolve=_resolve)
assert claim.result_type == "task"
assert claim.model is _TaskResult
assert claim.resolve is _resolve
assert claim.method == "tools/call"
assert claim.protocol_versions is None
def test_claim_accepts_modern_protocol_versions() -> None:
"""SDK-defined: a non-None `protocol_versions` subset of the modern revisions is accepted."""
versions = frozenset(MODERN_PROTOCOL_VERSIONS)
claim = _claim(protocol_versions=versions)
assert claim.protocol_versions == versions
def test_claim_rejects_core_result_type_vocabulary() -> None:
"""SDK-defined: a claim cannot re-key the core tags 'complete' and 'input_required'."""
messages: dict[str, str] = {}
for result_type in ("complete", "input_required"):
with pytest.raises(ValueError) as exc_info:
ResultClaim(result_type=result_type, model=_TaskResult, resolve=_resolve)
messages[result_type] = str(exc_info.value)
assert messages == snapshot(
{
"complete": "resultType 'complete' is core protocol vocabulary",
"input_required": "resultType 'input_required' is core protocol vocabulary",
}
)
@pytest.mark.parametrize("model", [_ClaimedCallToolResult, _ClaimedInputRequiredResult])
def test_claim_rejects_model_subclassing_core_result_types(model: type[Result]) -> None:
"""SDK-defined: a claim model subclassing a core result type is rejected; it would bypass claim routing."""
with pytest.raises(ValueError) as exc_info:
_claim(model=model)
assert str(exc_info.value) == snapshot("claim models must not subclass core result types")
def test_claim_rejects_model_without_result_type_field() -> None:
"""SDK-defined: the claim model must declare the discriminating `result_type` field."""
with pytest.raises(ValueError) as exc_info:
_claim(model=_UntaggedResult)
assert str(exc_info.value) == snapshot("_UntaggedResult.result_type must be Literal['task']")
def test_claim_rejects_plain_str_result_type_field() -> None:
"""SDK-defined: the model's `result_type` must be a Literal of the claimed tag, not a plain `str`."""
with pytest.raises(ValueError) as exc_info:
_claim(model=_PlainStringTagResult)
assert str(exc_info.value) == snapshot("_PlainStringTagResult.result_type must be Literal['task']")
def test_claim_rejects_mismatched_result_type_literal() -> None:
"""SDK-defined: the model's Literal tag must equal the claim's `result_type`."""
with pytest.raises(ValueError) as exc_info:
_claim(model=_OtherTagResult)
assert str(exc_info.value) == snapshot("_OtherTagResult.result_type must be Literal['task']")
class _NotAResult(BaseModel):
result_type: Literal["plain"] = "plain"
class _ReservedAliasResult(Result):
result_type: Literal["clash"] = "clash"
request_state: dict[str, Any] = {}
def test_claim_rejects_model_not_subclassing_result() -> None:
"""SDK-defined: a plain BaseModel cannot be a claim model; the session returns `Result` values."""
with pytest.raises(ValueError) as exc_info:
ResultClaim(result_type="plain", model=cast("type[Result]", _NotAResult), resolve=_resolve)
assert str(exc_info.value) == snapshot("_NotAResult must subclass mcp_types.Result")
def test_claim_rejects_model_aliasing_core_surface_fields() -> None:
"""SDK-defined: a field aliasing requestState or inputRequests would fail core pre-validation."""
with pytest.raises(ValueError) as exc_info:
ResultClaim(result_type="clash", model=_ReservedAliasResult, resolve=_resolve)
assert str(exc_info.value) == snapshot(
"_ReservedAliasResult.request_state aliases 'requestState', a typed field of the core "
"result surface; a colliding value would fail core validation before the claim adapter runs"
)
class _ValidationAliasResult(Result):
result_type: Literal["va"] = "va"
vendor_state: dict[str, Any] | None = Field(default=None, validation_alias="requestState")
class _SerializationAliasResult(Result):
result_type: Literal["sa"] = "sa"
vendor_state: dict[str, Any] | None = Field(default=None, serialization_alias="inputRequests")
class _AliasChoicesResult(Result):
result_type: Literal["ac"] = "ac"
vendor_state: dict[str, Any] | None = Field(
default=None, validation_alias=AliasChoices("vendorKey", "requestState")
)
class _AliasPathResult(Result):
result_type: Literal["ap"] = "ap"
vendor_state: dict[str, Any] | None = Field(
default=None, validation_alias=AliasChoices(AliasPath("requestState", "nested"))
)
def test_wire_keys_for_a_bare_field_is_just_its_name() -> None:
"""SDK-defined: a field with no aliases reads and writes only its own name."""
assert _wire_keys("plain", FieldInfo(annotation=str)) == frozenset({"plain"})
def test_claim_rejects_reserved_aliases_in_every_alias_form() -> None:
"""SDK-defined: validation_alias, serialization_alias, and AliasChoices routes to a reserved key are all caught."""
messages: dict[str, str] = {}
for model in (_ValidationAliasResult, _SerializationAliasResult, _AliasChoicesResult, _AliasPathResult):
with pytest.raises(ValueError) as exc_info:
ResultClaim(result_type=model.model_fields["result_type"].default, model=model, resolve=_resolve)
messages[model.__name__] = str(exc_info.value)
assert messages == snapshot(
{
"_ValidationAliasResult": "_ValidationAliasResult.vendor_state aliases "
"'requestState', a typed field of the core result surface; a colliding value would fail "
"core validation before the claim adapter runs",
"_SerializationAliasResult": "_SerializationAliasResult.vendor_state aliases "
"'inputRequests', a typed field of the core result surface; a colliding value would fail "
"core validation before the claim adapter runs",
"_AliasChoicesResult": "_AliasChoicesResult.vendor_state aliases 'requestState', a typed field of the core "
"result surface; a colliding value would fail core validation before the claim adapter runs",
"_AliasPathResult": "_AliasPathResult.vendor_state aliases "
"'requestState', a typed field of the core result surface; a colliding value would fail "
"core validation before the claim adapter runs",
}
)
def test_claim_rejects_method_outside_the_closed_verb_set() -> None:
"""SDK-defined: claims attach to `tools/call` only, even for values that dodge the static Literal gate."""
with pytest.raises(ValueError) as exc_info:
_claim(method=cast("Literal['tools/call']", "prompts/get"))
assert str(exc_info.value) == snapshot("claims attach to ['tools/call'] only; got method 'prompts/get'")
def test_claim_rejects_empty_protocol_versions() -> None:
"""SDK-defined: an empty version set is rejected; `None` is the spelling for every modern version."""
with pytest.raises(ValueError) as exc_info:
_claim(protocol_versions=frozenset())
assert str(exc_info.value) == snapshot("empty protocol_versions could never activate; use None for all")
def test_claim_rejects_non_modern_protocol_versions() -> None:
"""SDK-defined: a non-None version set must be a subset of the modern protocol revisions."""
messages: list[str] = []
for versions in (
frozenset({"2025-11-25"}),
frozenset({"2026-07-28", "2025-11-25"}),
frozenset({"never-a-version"}),
):
with pytest.raises(ValueError) as exc_info:
_claim(protocol_versions=versions)
messages.append(str(exc_info.value))
assert messages == snapshot(
[
"protocol_versions ['2025-11-25'] are not modern protocol revisions; claimed shapes "
"cannot be delivered on a legacy wire (None means every modern version)",
"protocol_versions ['2025-11-25'] are not modern protocol revisions; claimed shapes "
"cannot be delivered on a legacy wire (None means every modern version)",
"protocol_versions ['never-a-version'] are not modern protocol revisions; claimed shapes "
"cannot be delivered on a legacy wire (None means every modern version)",
]
)
def test_result_claim_is_frozen() -> None:
"""SDK-defined: claims are immutable; mutating one after construction raises."""
claim = _claim()
with pytest.raises(FrozenInstanceError):
setattr(claim, "result_type", "other") # direct assignment is also a type error
class _TaskNotificationParams(BaseModel):
task_id: str
async def _on_task(params: _TaskNotificationParams) -> None:
raise NotImplementedError
def test_notification_binding_constructs() -> None:
"""SDK-defined: a binding is a bare declaration with no construction-time validation."""
binding = NotificationBinding(method="notifications/tasks", params_type=_TaskNotificationParams, handler=_on_task)
assert binding.method == "notifications/tasks"
assert binding.params_type is _TaskNotificationParams
assert binding.handler is _on_task
def test_notification_binding_accepts_core_known_method() -> None:
"""SDK-defined: deliberately no spec-table check at construction, so packages survive core adopting a method."""
binding = NotificationBinding(
method="notifications/progress", params_type=_TaskNotificationParams, handler=_on_task
)
assert binding.method == "notifications/progress"
def test_notification_binding_is_frozen() -> None:
"""SDK-defined: bindings are immutable; mutating one after construction raises."""
binding = NotificationBinding(method="notifications/tasks", params_type=_TaskNotificationParams, handler=_on_task)
with pytest.raises(FrozenInstanceError):
setattr(binding, "method", "notifications/other") # direct assignment is also a type error
def test_extension_defaults_advertise_nothing() -> None:
"""SDK-defined: a minimal subclass advertises empty settings, no claims, and no bindings."""
class _MinimalExt(ClientExtension):
identifier = "com.example/minimal"
ext = _MinimalExt()
assert ext.settings() == {}
assert ext.claims() == ()
assert ext.notifications() == ()
@pytest.mark.parametrize(
"identifier",
[
"io.modelcontextprotocol/ui",
"com.example/my_ext",
"com.x-y.z2/n.a-b_c",
"example/x",
"a/b",
"com.example/9start",
],
)
def test_grammar_conformant_identifiers_accepted_at_class_definition(identifier: str) -> None:
"""Spec `_meta` key grammar: conformant `vendor-prefix/name` identifiers are accepted."""
cls = type("_GoodExt", (ClientExtension,), {"identifier": identifier})
assert cls.identifier == identifier
@pytest.mark.parametrize(
"identifier",
[
"noprefix",
"-foo/bar",
".leading/x",
"a..b/x",
"foo-/x",
"9foo/x",
"foo/-bar",
"foo/bar-",
"foo/",
"/bar",
"foo/ba r",
"io.modelcontextprotocol/ui\n",
"",
42,
],
)
def test_malformed_identifier_rejected_at_class_definition(identifier: Any) -> None:
"""SDK-defined: the SEP-2133 `vendor-prefix/name` grammar is enforced the moment the subclass is defined."""
with pytest.raises(TypeError):
type("_BadExt", (ClientExtension,), {"identifier": identifier})
def test_subclass_without_identifier_allowed_at_definition() -> None:
"""SDK-defined: a subclass with no class-level `identifier` is allowed; validation waits for consumption."""
class _AbstractishExt(ClientExtension):
"""Intermediate base; concrete subclasses supply the identifier."""
class _ConcreteExt(_AbstractishExt):
identifier = "com.example/concrete"
assert _ConcreteExt.identifier == "com.example/concrete"
def test_advertise_serves_captured_settings() -> None:
"""SDK-defined: `advertise()` returns an ad-only extension serving the captured settings."""
ext = advertise("com.example/flags", {"enabled": True})
assert isinstance(ext, ClientExtension)
assert ext.identifier == "com.example/flags"
assert ext.settings() == {"enabled": True}
assert ext.claims() == ()
assert ext.notifications() == ()
def test_advertise_defaults_to_empty_settings() -> None:
"""SDK-defined: omitting settings advertises the extension with an empty map."""
ext = advertise("com.example/flags")
assert ext.settings() == {}
@pytest.mark.parametrize("identifier", ["noprefix", "foo/", ""])
def test_advertise_validates_identifier_eagerly(identifier: str) -> None:
"""SDK-defined: `advertise()` validates the identifier eagerly, at the call site."""
with pytest.raises(TypeError):
advertise(identifier)
+173
View File
@@ -0,0 +1,173 @@
"""Tests for Unicode handling in streamable HTTP transport.
Verifies that Unicode text is correctly transmitted and received in both directions
(server→client and client→server) using the streamable HTTP transport.
"""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import httpx
import mcp_types as types
import pytest
from mcp_types import TextContent, Tool
from starlette.applications import Starlette
from starlette.routing import Mount
from mcp.client.session import ClientSession
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server, ServerRequestContext
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from tests.interaction.transports import StreamingASGITransport
# The in-process app is mounted at this origin purely so URLs are well-formed; nothing listens here.
BASE_URL = "http://127.0.0.1:8000"
# Test constants with various Unicode characters
UNICODE_TEST_STRINGS = {
"cyrillic": "Слой хранилища, где располагаются",
"cyrillic_short": "Привет мир",
"chinese": "你好世界 - 这是一个测试",
"japanese": "こんにちは世界 - これはテストです",
"korean": "안녕하세요 세계 - 이것은 테스트입니다",
"arabic": "مرحبا بالعالم - هذا اختبار",
"hebrew": "שלום עולם - זה מבחן",
"greek": "Γεια σου κόσμε - αυτό είναι δοκιμή",
"emoji": "Hello 👋 World 🌍 - Testing 🧪 Unicode ✨",
"math": "∑ ∫ √ ∞ ≠ ≤ ≥ ∈ ∉ ⊆ ⊇",
"accented": "Café, naïve, résumé, piñata, Zürich",
"mixed": "Hello世界🌍Привет안녕مرحباשלום",
"special": "Line\nbreak\ttab\r\nCRLF",
"quotes": '«French» „German" "English" 「Japanese」',
"currency": "€100 £50 ¥1000 ₹500 ₽200 ¢99",
}
async def handle_list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[
Tool(
name="echo_unicode",
description="🔤 Echo Unicode text - Hello 👋 World 🌍 - Testing 🧪 Unicode ✨",
input_schema={
"type": "object",
"properties": {
"text": {"type": "string", "description": "Text to echo back"},
},
"required": ["text"],
},
),
]
)
async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
assert params.name == "echo_unicode"
assert params.arguments is not None
return types.CallToolResult(content=[TextContent(type="text", text=f"Echo: {params.arguments['text']}")])
async def handle_list_prompts(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListPromptsResult:
return types.ListPromptsResult(
prompts=[
types.Prompt(
name="unicode_prompt",
description="Unicode prompt - Слой хранилища, где располагаются",
arguments=[],
)
]
)
async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.GetPromptResult:
assert params.name == "unicode_prompt"
return types.GetPromptResult(
messages=[
types.PromptMessage(
role="user",
content=types.TextContent(type="text", text="Hello世界🌍Привет안녕مرحباשלום"),
)
]
)
@asynccontextmanager
async def unicode_session() -> AsyncIterator[ClientSession]:
"""Yield an initialized ClientSession speaking streamable HTTP (SSE responses) to the
Unicode test server, entirely in process."""
server = Server(
name="unicode_test_server",
on_list_tools=handle_list_tools,
on_call_tool=handle_call_tool,
on_list_prompts=handle_list_prompts,
on_get_prompt=handle_get_prompt,
)
# SSE response mode, so Unicode rides the SSE event encoding rather than a plain JSON body.
session_manager = StreamableHTTPSessionManager(app=server, json_response=False)
app = Starlette(routes=[Mount("/mcp", app=session_manager.handle_request)])
async with (
session_manager.run(),
# follow_redirects matches the SDK's own client factory; Starlette's Mount 307-redirects
# the bare /mcp path to /mcp/.
httpx.AsyncClient(
transport=StreamingASGITransport(app), base_url=BASE_URL, follow_redirects=True
) as http_client,
streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client) as (read_stream, write_stream),
ClientSession(read_stream, write_stream) as session,
):
await session.initialize()
yield session
@pytest.mark.anyio
async def test_streamable_http_client_unicode_tool_call() -> None:
"""Test that Unicode text is correctly handled in tool calls via streamable HTTP."""
async with unicode_session() as session:
# Test 1: List tools (server→client Unicode in descriptions)
tools = await session.list_tools()
assert len(tools.tools) == 1
# Check Unicode in tool descriptions
echo_tool = tools.tools[0]
assert echo_tool.name == "echo_unicode"
assert echo_tool.description is not None
assert "🔤" in echo_tool.description
assert "👋" in echo_tool.description
# Test 2: Send Unicode text in tool call (client→server→client)
for test_name, test_string in UNICODE_TEST_STRINGS.items():
result = await session.call_tool("echo_unicode", arguments={"text": test_string})
# Verify server correctly received and echoed back Unicode
assert len(result.content) == 1
content = result.content[0]
assert content.type == "text"
assert f"Echo: {test_string}" == content.text, f"Failed for {test_name}"
@pytest.mark.anyio
async def test_streamable_http_client_unicode_prompts() -> None:
"""Test that Unicode text is correctly handled in prompts via streamable HTTP."""
async with unicode_session() as session:
# Test 1: List prompts (server→client Unicode in descriptions)
prompts = await session.list_prompts()
assert len(prompts.prompts) == 1
prompt = prompts.prompts[0]
assert prompt.name == "unicode_prompt"
assert prompt.description is not None
assert "Слой хранилища, где располагаются" in prompt.description
# Test 2: Get prompt with Unicode content (server→client)
result = await session.get_prompt("unicode_prompt", arguments={})
assert len(result.messages) == 1
message = result.messages[0]
assert message.role == "user"
assert message.content.type == "text"
assert message.content.text == "Hello世界🌍Привет안녕مرحباשלום"
+271
View File
@@ -0,0 +1,271 @@
"""Unit tests for the SEP-2322 client-side multi-round-trip driver.
`run_input_required_driver` is pure: it takes the first `InputRequiredResult`
plus `dispatch` / `retry` closures and loops until a terminal result. These
tests build those closures by hand (scripted lists, recording lists) so the
driver is exercised without a `ClientSession`. Integration against a real
server lives in `test_client.py`.
"""
import anyio
import pytest
from inline_snapshot import snapshot
from mcp_types import (
INVALID_REQUEST,
CallToolResult,
ElicitRequest,
ElicitRequestFormParams,
ElicitResult,
ErrorData,
InputRequest,
InputRequiredResult,
InputResponse,
InputResponses,
TextContent,
)
from trio.testing import MockClock
from mcp import MCPError
from mcp.client._input_required import (
_STATE_ONLY_BACKOFF_CAP_SECONDS,
_STATE_ONLY_BACKOFF_INITIAL_SECONDS,
DEFAULT_INPUT_REQUIRED_MAX_ROUNDS,
InputRequiredRoundsExceededError,
run_input_required_driver,
)
pytestmark = pytest.mark.anyio
@pytest.fixture(autouse=True)
def _module_runner_lease() -> None:
"""Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`."""
def _elicit(message: str = "What is your name?") -> ElicitRequest:
schema = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}
return ElicitRequest(params=ElicitRequestFormParams(message=message, requested_schema=schema))
async def _never_dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
"""Dispatch closure for tests whose script never carries `input_requests`."""
raise NotImplementedError
async def test_single_round_dispatches_then_retries_to_terminal_result() -> None:
"""One `InputRequiredResult` with one elicit request: dispatch runs once,
retry runs once with the collected response, and the terminal result is returned."""
first = InputRequiredResult(input_requests={"ask": _elicit()})
terminal = CallToolResult(content=[TextContent(text="done")])
dispatched: list[tuple[str, InputRequest]] = []
retried: list[tuple[InputResponses | None, str | None]] = []
async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
dispatched.append((key, req))
return ElicitResult(action="accept", content={"name": "Ada"})
async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult:
retried.append((responses, state))
return terminal
with anyio.fail_after(5):
result = await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=3)
assert result is terminal
assert first.input_requests is not None
assert dispatched == [("ask", first.input_requests["ask"])]
assert retried == [({"ask": ElicitResult(action="accept", content={"name": "Ada"})}, None)]
async def test_multi_round_loops_until_retry_returns_non_input_required() -> None:
"""Two consecutive `InputRequiredResult` legs followed by a terminal result:
the driver dispatches and retries each leg in order."""
terminal = CallToolResult(content=[TextContent(text="done")])
script: list[CallToolResult | InputRequiredResult] = [
InputRequiredResult(input_requests={"b": _elicit("second?")}),
terminal,
]
retried: list[tuple[InputResponses | None, str | None]] = []
dispatched_keys: list[str] = []
async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
dispatched_keys.append(key)
return ElicitResult(action="decline")
async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult:
retried.append((responses, state))
return script.pop(0)
first = InputRequiredResult(input_requests={"a": _elicit("first?")})
with anyio.fail_after(5):
result = await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=5)
assert result is terminal
assert dispatched_keys == ["a", "b"]
assert retried == snapshot(
[
({"a": ElicitResult(action="decline")}, None),
({"b": ElicitResult(action="decline")}, None),
]
)
async def test_exceeding_max_rounds_raises_with_the_configured_cap() -> None:
"""When every retry returns another `InputRequiredResult`, the driver gives
up after `max_rounds` retries with `InputRequiredRoundsExceededError`."""
rounds: list[int] = []
async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
return ElicitResult(action="decline")
async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult:
rounds.append(len(rounds))
return InputRequiredResult(input_requests={"again": _elicit()})
first = InputRequiredResult(input_requests={"again": _elicit()})
with anyio.fail_after(5):
with pytest.raises(InputRequiredRoundsExceededError) as exc:
await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=3)
assert exc.value.max_rounds == 3
# `first` counts as round 1; rounds 1-3 each retry, round 4 trips the cap before dispatching.
assert len(rounds) == 3
async def test_dispatch_returning_error_data_aborts_the_loop_as_mcp_error() -> None:
"""SDK-defined: a callback that refuses an embedded request returns
`ErrorData`; the driver surfaces it as `MCPError` rather than retrying."""
async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
return ErrorData(code=INVALID_REQUEST, message="not supported")
async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult:
raise NotImplementedError # unreachable: dispatch errored before any retry
first = InputRequiredResult(input_requests={"ask": _elicit()})
with anyio.fail_after(5):
with pytest.raises(MCPError) as exc:
await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=3)
assert exc.value.error.code == INVALID_REQUEST
async def test_request_state_passes_through_byte_identical() -> None:
"""`request_state` is opaque to the driver: each leg's value reaches `retry`
as the same object the server sent, never parsed or rebuilt."""
states = ['{"round": 1, "tag": "héllo"}', '{"round": 2, "tag": "wörld"}']
received_states: list[str | None] = []
async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
return ElicitResult(action="decline")
async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult:
received_states.append(state)
if len(received_states) < 2:
return InputRequiredResult(input_requests={"k": _elicit()}, request_state=states[1])
return CallToolResult(content=[])
first = InputRequiredResult(input_requests={"k": _elicit()}, request_state=states[0])
with anyio.fail_after(5):
await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=3)
assert received_states[0] is states[0]
assert received_states[1] is states[1]
# Runs on trio's autojumping virtual clock so the backoff sleeps add zero
# wall-clock and the recorded deltas are exact: `anyio.sleep` advances the
# MockClock by precisely the requested duration once every task is idle.
@pytest.mark.parametrize(
"anyio_backend",
[pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")],
)
async def test_state_only_legs_back_off_exponentially_to_the_cap() -> None:
"""SDK-defined pacing: state-only legs sleep 50ms, 100ms, 200ms, then cap at
250ms. Six state-only rounds → deltas `[0.05, 0.1, 0.2, 0.25, 0.25, 0.25]`."""
retry_times: list[float] = []
async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult:
retry_times.append(anyio.current_time())
assert responses is None
if len(retry_times) == 6:
return CallToolResult(content=[])
return InputRequiredResult(request_state="poll")
start = anyio.current_time()
first = InputRequiredResult(request_state="poll")
await run_input_required_driver(first, dispatch=_never_dispatch, retry=retry, max_rounds=10)
deltas = [round(retry_times[0] - start, 9)] + [
round(retry_times[i] - retry_times[i - 1], 9) for i in range(1, len(retry_times))
]
assert deltas == snapshot([0.05, 0.1, 0.2, 0.25, 0.25, 0.25])
assert _STATE_ONLY_BACKOFF_INITIAL_SECONDS == 0.05
assert _STATE_ONLY_BACKOFF_CAP_SECONDS == 0.25
@pytest.mark.parametrize(
"anyio_backend",
[pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")],
)
async def test_backoff_counter_resets_after_a_leg_with_input_requests() -> None:
"""A leg carrying `input_requests` resets `consecutive_state_only`: the
next state-only leg sleeps the initial 50ms again, not the prior position."""
# state-only, state-only, dispatch leg (no sleep), state-only, terminal.
script: list[CallToolResult | InputRequiredResult] = [
InputRequiredResult(request_state="s"),
InputRequiredResult(input_requests={"k": _elicit()}),
InputRequiredResult(request_state="s"),
CallToolResult(content=[]),
]
retry_times: list[float] = []
async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
return ElicitResult(action="decline")
async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult:
retry_times.append(anyio.current_time())
return script.pop(0)
start = anyio.current_time()
first = InputRequiredResult(request_state="s")
await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=10)
deltas = [round(retry_times[0] - start, 9)] + [
round(retry_times[i] - retry_times[i - 1], 9) for i in range(1, len(retry_times))
]
# 0.05, 0.1 (two state-only), 0.0 (dispatch leg has no sleep), 0.05 (reset).
assert deltas == snapshot([0.05, 0.1, 0.0, 0.05])
async def test_input_requests_are_dispatched_concurrently() -> None:
"""All `input_requests` in a round are dispatched together: each dispatch
blocks on a shared gate that only opens once every key has started, so a
sequential implementation would deadlock under the `fail_after`."""
keys = ["a", "b", "c"]
started: set[str] = set()
all_started = anyio.Event()
async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
started.add(key)
if started == set(keys):
all_started.set()
await all_started.wait() # blocks until every sibling is in-flight
return ElicitResult(action="accept", content={"name": key})
received: list[InputResponses | None] = []
async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult:
received.append(responses)
return CallToolResult(content=[])
first = InputRequiredResult(input_requests={k: _elicit() for k in keys})
with anyio.fail_after(5):
await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=2)
assert received[0] is not None
assert received[0] == {k: ElicitResult(action="accept", content={"name": k}) for k in keys}
def test_default_max_rounds_constant() -> None:
"""SDK-defined default; matches the typescript-sdk."""
assert DEFAULT_INPUT_REQUIRED_MAX_ROUNDS == 10
+126
View File
@@ -0,0 +1,126 @@
from collections.abc import Callable
import mcp_types as types
import pytest
from mcp_types import ListToolsResult
from mcp import Client
from mcp.server import Server, ServerRequestContext
from mcp.server.mcpserver import MCPServer
from .conftest import StreamSpyCollection
pytestmark = pytest.mark.anyio
@pytest.fixture
async def full_featured_server():
"""Create a server with tools, resources, prompts, and templates."""
server = MCPServer("test")
# pragma: no cover on handlers below - these exist only to register items with the
# server so list_* methods return results. The handlers themselves are never called
# because these tests only verify pagination/cursor behavior, not tool/resource invocation.
@server.tool()
def greet(name: str) -> str: # pragma: no cover
"""Greet someone by name."""
return f"Hello, {name}!"
@server.resource("test://resource")
def test_resource() -> str: # pragma: no cover
"""A test resource."""
return "Test content"
@server.resource("test://template/{id}")
def test_template(id: str) -> str: # pragma: no cover
"""A test resource template."""
return f"Template content for {id}"
@server.prompt()
def greeting_prompt(name: str) -> str: # pragma: no cover
"""A greeting prompt."""
return f"Please greet {name}."
return server
@pytest.mark.parametrize(
"method_name,request_method",
[
("list_tools", "tools/list"),
("list_resources", "resources/list"),
("list_prompts", "prompts/list"),
("list_resource_templates", "resources/templates/list"),
],
)
async def test_list_methods_params_parameter(
stream_spy: Callable[[], StreamSpyCollection],
full_featured_server: MCPServer,
method_name: str,
request_method: str,
):
"""Test that the params parameter is accepted and correctly passed to the server.
Covers: list_tools, list_resources, list_prompts, list_resource_templates
See: https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/pagination#request-format
"""
async with Client(full_featured_server, mode="legacy") as client:
spies = stream_spy()
# Test without params (omitted)
method = getattr(client, method_name)
_ = await method()
requests = spies.get_client_requests(method=request_method)
assert len(requests) == 1
assert requests[0].params is None or "cursor" not in requests[0].params
spies.clear()
# Test with params containing cursor
_ = await method(cursor="from_params")
requests = spies.get_client_requests(method=request_method)
assert len(requests) == 1
assert requests[0].params is not None
assert requests[0].params["cursor"] == "from_params"
spies.clear()
# Test with empty params
_ = await method()
requests = spies.get_client_requests(method=request_method)
assert len(requests) == 1
# Empty params means no cursor
assert requests[0].params is None or "cursor" not in requests[0].params
async def test_list_tools_with_strict_server_validation(
full_featured_server: MCPServer,
):
"""Test pagination with a server that validates request format strictly."""
async with Client(full_featured_server) as client:
result = await client.list_tools()
assert isinstance(result, ListToolsResult)
assert len(result.tools) > 0
async def test_list_tools_with_lowlevel_server():
"""Test that list_tools works with a lowlevel Server using params."""
async def handle_list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> ListToolsResult:
# Echo back what cursor we received in the tool description
cursor = params.cursor if params else None
return ListToolsResult(
tools=[types.Tool(name="test_tool", description=f"cursor={cursor}", input_schema={"type": "object"})]
)
server = Server("test-lowlevel", on_list_tools=handle_list_tools)
async with Client(server) as client:
result = await client.list_tools()
assert result.tools[0].description == "cursor=None"
result = await client.list_tools(cursor="page2")
assert result.tools[0].description == "cursor=page2"
+47
View File
@@ -0,0 +1,47 @@
import pytest
from mcp_types import INVALID_REQUEST, ListRootsResult, Root, TextContent
from pydantic import FileUrl
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared.exceptions import MCPError
@pytest.mark.anyio
async def test_list_roots_callback():
server = MCPServer("test")
callback_return = ListRootsResult(
roots=[
Root(uri=FileUrl("file://users/fake/test"), name="Test Root 1"),
Root(uri=FileUrl("file://users/fake/test/2"), name="Test Root 2"),
]
)
async def list_roots_callback(
context: ClientRequestContext,
) -> ListRootsResult:
return callback_return
@server.tool("test_list_roots")
async def test_list_roots(context: Context, message: str):
roots = await context.session.list_roots() # pyright: ignore[reportDeprecated]
assert roots == callback_return
return True
# Test with list_roots callback
async with Client(server, list_roots_callback=list_roots_callback, mode="legacy") as client:
# Make a request to trigger sampling callback
result = await client.call_tool("test_list_roots", {"message": "test message"})
assert result.is_error is False
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "true"
# Without a list_roots callback the client responds with an MCPError, which the
# tool body doesn't catch — the wrapper re-raises it as a top-level JSON-RPC
# error rather than wrapping it as an isError result.
async with Client(server, mode="legacy") as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("test_list_roots", {"message": "test message"})
assert exc_info.value.error.code == INVALID_REQUEST
+108
View File
@@ -0,0 +1,108 @@
from typing import Literal
import mcp_types as types
import pytest
from mcp_types import (
LoggingMessageNotificationParams,
TextContent,
)
from mcp import Client
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared.session import RequestResponder
class LoggingCollector:
def __init__(self):
self.log_messages: list[LoggingMessageNotificationParams] = []
async def __call__(self, params: LoggingMessageNotificationParams) -> None:
self.log_messages.append(params)
@pytest.mark.anyio
async def test_logging_callback():
server = MCPServer("test")
logging_collector = LoggingCollector()
# Create a simple test tool
@server.tool("test_tool")
async def test_tool() -> bool:
# The actual tool is very simple and just returns True
return True
# Create a function that can send a log notification
@server.tool("test_tool_with_log")
async def test_tool_with_log(
message: str, level: Literal["debug", "info", "warning", "error"], logger: str, ctx: Context
) -> bool:
"""Send a log notification to the client."""
await ctx.log(level=level, data=message, logger_name=logger) # pyright: ignore[reportDeprecated]
return True
@server.tool("test_tool_with_log_dict")
async def test_tool_with_log_dict(
level: Literal["debug", "info", "warning", "error"],
logger: str,
ctx: Context,
) -> bool:
"""Send a log notification with a dict payload."""
await ctx.log( # pyright: ignore[reportDeprecated]
level=level,
data={"message": "Test log message", "extra_string": "example", "extra_dict": {"a": 1, "b": 2, "c": 3}},
logger_name=logger,
)
return True
# Create a message handler to catch exceptions
async def message_handler(
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
) -> None:
if isinstance(message, Exception): # pragma: no cover
raise message
async with Client(
server,
logging_callback=logging_collector,
message_handler=message_handler,
mode="legacy",
) as client:
# First verify our test tool works
result = await client.call_tool("test_tool", {})
assert result.is_error is False
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "true"
# Now send a log message via our tool
log_result = await client.call_tool(
"test_tool_with_log",
{
"message": "Test log message",
"level": "info",
"logger": "test_logger",
},
)
log_result_with_dict = await client.call_tool(
"test_tool_with_log_dict",
{
"level": "info",
"logger": "test_logger",
},
)
assert log_result.is_error is False
assert log_result_with_dict.is_error is False
assert len(logging_collector.log_messages) == 2
# Create meta object with related_request_id added dynamically
log = logging_collector.log_messages[0]
assert log.level == "info"
assert log.logger == "test_logger"
assert log.data == "Test log message"
log_with_dict = logging_collector.log_messages[1]
assert log_with_dict.level == "info"
assert log_with_dict.logger == "test_logger"
assert log_with_dict.data == {
"message": "Test log message",
"extra_string": "example",
"extra_dict": {"a": 1, "b": 2, "c": 3},
}
+270
View File
@@ -0,0 +1,270 @@
"""Tests for StreamableHTTP client transport with non-SDK servers.
These tests verify client behavior when interacting with servers
that don't follow SDK conventions.
"""
import json
import httpx
import mcp_types as types
import pytest
from mcp_types import RootsListChangedNotification
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.routing import Route
from mcp import ClientSession, MCPError
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.session import RequestResponder
pytestmark = pytest.mark.anyio
INIT_RESPONSE = {
"serverInfo": {"name": "test-non-sdk-server", "version": "1.0.0"},
"protocolVersion": "2024-11-05",
"capabilities": {},
}
def _init_json_response(data: dict[str, object]) -> JSONResponse:
return JSONResponse({"jsonrpc": "2.0", "id": data["id"], "result": INIT_RESPONSE})
def _create_non_sdk_server_app() -> Starlette:
"""Create a minimal server that doesn't follow SDK conventions."""
async def handle_mcp_request(request: Request) -> Response:
body = await request.body()
data = json.loads(body)
if data.get("method") == "initialize":
return _init_json_response(data)
# For notifications, return 204 No Content (non-SDK behavior)
if "id" not in data:
return Response(status_code=204, headers={"Content-Type": "application/json"})
return JSONResponse( # pragma: no cover
{"jsonrpc": "2.0", "id": data.get("id"), "error": {"code": -32601, "message": "Method not found"}}
)
return Starlette(debug=True, routes=[Route("/mcp", handle_mcp_request, methods=["POST"])])
def _create_unexpected_content_type_app() -> Starlette:
"""Create a server that returns an unexpected content type for requests."""
async def handle_mcp_request(request: Request) -> Response:
body = await request.body()
data = json.loads(body)
if data.get("method") == "initialize":
return _init_json_response(data)
if "id" not in data:
return Response(status_code=202)
# Return text/plain for all other requests — an unexpected content type.
return Response(content="this is plain text, not json or sse", status_code=200, media_type="text/plain")
return Starlette(debug=True, routes=[Route("/mcp", handle_mcp_request, methods=["POST"])])
async def test_non_compliant_notification_response() -> None:
"""Verify the client ignores unexpected responses to notifications.
The spec states notifications should get either 202 + no response body, or 4xx + optional error body
(https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#sending-messages-to-the-server),
but some servers wrongly return other 2xx codes (e.g. 204). For now we simply ignore unexpected responses
(aligning behaviour w/ the TS SDK).
"""
returned_exception = None
async def message_handler( # pragma: no cover
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
) -> None:
nonlocal returned_exception
if isinstance(message, Exception):
returned_exception = message
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_create_non_sdk_server_app())) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session:
await session.initialize()
# The test server returns a 204 instead of the expected 202
await session.send_notification(RootsListChangedNotification(method="notifications/roots/list_changed"))
if returned_exception: # pragma: no cover
pytest.fail(f"Server encountered an exception: {returned_exception}")
async def test_unexpected_content_type_sends_jsonrpc_error() -> None:
"""Verify unexpected content types unblock the pending request with an MCPError.
When a server returns a content type that is neither application/json nor text/event-stream,
the client should send a JSONRPCError so the pending request resolves immediately
instead of hanging until timeout.
"""
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_create_unexpected_content_type_app())) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
with pytest.raises(MCPError, match="Unexpected content type: text/plain"): # pragma: no branch
await session.list_tools()
def _create_http_error_app(error_status: int, *, error_on_notifications: bool = False) -> Starlette:
"""Create a server that returns an HTTP error for non-init requests."""
async def handle_mcp_request(request: Request) -> Response:
body = await request.body()
data = json.loads(body)
if data.get("method") == "initialize":
return _init_json_response(data)
if "id" not in data:
if error_on_notifications:
return Response(status_code=error_status)
return Response(status_code=202)
return Response(status_code=error_status)
return Starlette(debug=True, routes=[Route("/mcp", handle_mcp_request, methods=["POST"])])
async def test_http_error_status_sends_jsonrpc_error() -> None:
"""Verify HTTP 5xx errors unblock the pending request with an MCPError.
When a server returns a non-2xx status code (e.g. 500), the client should
send a JSONRPCError so the pending request resolves immediately instead of
raising an unhandled httpx.HTTPStatusError that causes the caller to hang.
"""
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_create_http_error_app(500))) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
with pytest.raises(MCPError, match="Server returned an error response"): # pragma: no branch
await session.list_tools()
async def test_http_error_on_notification_does_not_hang() -> None:
"""Verify HTTP errors on notifications are silently ignored.
When a notification gets an HTTP error, there is no pending request to
unblock, so the client should just return without sending a JSONRPCError.
"""
app = _create_http_error_app(500, error_on_notifications=True)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
# Should not raise or hang — the error is silently ignored for notifications
await session.send_notification(RootsListChangedNotification(method="notifications/roots/list_changed"))
def _create_invalid_json_response_app() -> Starlette:
"""Create a server that returns invalid JSON for requests."""
async def handle_mcp_request(request: Request) -> Response:
body = await request.body()
data = json.loads(body)
if data.get("method") == "initialize":
return _init_json_response(data)
if "id" not in data:
return Response(status_code=202)
# Return application/json content type but with invalid JSON body.
return Response(content="not valid json{{{", status_code=200, media_type="application/json")
return Starlette(debug=True, routes=[Route("/mcp", handle_mcp_request, methods=["POST"])])
async def test_invalid_json_response_sends_jsonrpc_error() -> None:
"""Verify invalid JSON responses unblock the pending request with an MCPError.
When a server returns application/json with an unparseable body, the client
should send a JSONRPCError so the pending request resolves immediately
instead of hanging until timeout.
"""
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_create_invalid_json_response_app())) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
with pytest.raises(MCPError, match="Failed to parse JSON response"): # pragma: no branch
await session.list_tools()
def _create_non_2xx_json_body_app(status: int, body: bytes) -> Starlette:
"""Server that returns a fixed non-2xx status + ``application/json`` body for non-init requests.
The initialize response carries an ``mcp-session-id`` so the client treats subsequent
requests as part of an established session (needed for the 404 → session-terminated mapping).
"""
async def handle_mcp_request(request: Request) -> Response:
data = json.loads(await request.body())
if data.get("method") == "initialize":
return JSONResponse(
{"jsonrpc": "2.0", "id": data["id"], "result": INIT_RESPONSE},
headers={"mcp-session-id": "test-session"},
)
if "id" not in data:
return Response(status_code=202)
return Response(content=body, status_code=status, media_type="application/json")
return Starlette(debug=True, routes=[Route("/mcp", handle_mcp_request, methods=["POST"])])
async def test_client_surfaces_jsonrpc_error_from_non_2xx_body_with_correlated_id() -> None:
"""SDK-defined: a JSON-RPC error in a non-2xx body is surfaced verbatim even when the
server set ``id: null`` — the client rewraps it under the pending request's id, so
the awaiting call resolves with the server's error code instead of the generic fallback."""
body = json.dumps(
{"jsonrpc": "2.0", "id": None, "error": {"code": types.METHOD_NOT_FOUND, "message": "nope"}}
).encode()
app = _create_non_2xx_json_body_app(400, body)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
with pytest.raises(MCPError) as exc:
await session.list_tools()
assert exc.value.error.code == types.METHOD_NOT_FOUND
async def test_client_falls_back_to_generic_error_when_non_2xx_body_is_a_jsonrpc_result() -> None:
"""SDK-defined: a non-2xx response whose JSON body parses as a JSON-RPC *result* (not an
error) falls through to the generic ``INTERNAL_ERROR`` fallback rather than being
treated as the request's reply."""
app = _create_non_2xx_json_body_app(400, b'{"jsonrpc":"2.0","id":1,"result":{}}')
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
with pytest.raises(MCPError) as exc:
await session.list_tools()
assert exc.value.error.code == types.INTERNAL_ERROR
async def test_client_falls_back_to_session_terminated_when_404_body_is_malformed_json() -> None:
"""SDK-defined: an unparseable ``application/json`` body on a 404 response is swallowed
and the status-derived ``INVALID_REQUEST`` (session-terminated) fallback resolves the
pending request — the parse failure never propagates."""
app = _create_non_2xx_json_body_app(404, b"not valid json{{{")
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
with pytest.raises(MCPError) as exc:
await session.list_tools()
assert exc.value.error.code == types.INVALID_REQUEST
@@ -0,0 +1,165 @@
import logging
from typing import Any
import pytest
from mcp_types import (
CallToolRequestParams,
CallToolResult,
ListToolsResult,
PaginatedRequestParams,
TextContent,
Tool,
)
from mcp import Client
from mcp.server import Server, ServerRequestContext
def _make_server(
tools: list[Tool],
structured_content: dict[str, Any],
) -> Server:
"""Create a low-level server that returns the given structured_content for any tool call."""
async def on_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=tools)
async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
return CallToolResult(
content=[TextContent(type="text", text="result")],
structured_content=structured_content,
)
return Server("test-server", on_list_tools=on_list_tools, on_call_tool=on_call_tool)
@pytest.mark.anyio
async def test_tool_structured_output_client_side_validation_basemodel():
"""Test that client validates structured content against schema for BaseModel outputs"""
output_schema = {
"type": "object",
"properties": {"name": {"type": "string", "title": "Name"}, "age": {"type": "integer", "title": "Age"}},
"required": ["name", "age"],
"title": "UserOutput",
}
server = _make_server(
tools=[
Tool(
name="get_user",
description="Get user data",
input_schema={"type": "object"},
output_schema=output_schema,
)
],
structured_content={"name": "John", "age": "invalid"}, # Invalid: age should be int
)
async with Client(server) as client:
with pytest.raises(RuntimeError) as exc_info:
await client.call_tool("get_user", {})
assert "Invalid structured content returned by tool get_user" in str(exc_info.value)
@pytest.mark.anyio
async def test_tool_structured_output_client_side_validation_primitive():
"""Test that client validates structured content for primitive outputs"""
output_schema = {
"type": "object",
"properties": {"result": {"type": "integer", "title": "Result"}},
"required": ["result"],
"title": "calculate_Output",
}
server = _make_server(
tools=[
Tool(
name="calculate",
description="Calculate something",
input_schema={"type": "object"},
output_schema=output_schema,
)
],
structured_content={"result": "not_a_number"}, # Invalid: should be int
)
async with Client(server) as client:
with pytest.raises(RuntimeError) as exc_info:
await client.call_tool("calculate", {})
assert "Invalid structured content returned by tool calculate" in str(exc_info.value)
@pytest.mark.anyio
async def test_tool_structured_output_client_side_validation_dict_typed():
"""Test that client validates dict[str, T] structured content"""
output_schema = {"type": "object", "additionalProperties": {"type": "integer"}, "title": "get_scores_Output"}
server = _make_server(
tools=[
Tool(
name="get_scores",
description="Get scores",
input_schema={"type": "object"},
output_schema=output_schema,
)
],
structured_content={"alice": "100", "bob": "85"}, # Invalid: values should be int
)
async with Client(server) as client:
with pytest.raises(RuntimeError) as exc_info:
await client.call_tool("get_scores", {})
assert "Invalid structured content returned by tool get_scores" in str(exc_info.value)
@pytest.mark.anyio
async def test_tool_structured_output_client_side_validation_missing_required():
"""Test that client validates missing required fields"""
output_schema = {
"type": "object",
"properties": {"name": {"type": "string"}, "age": {"type": "integer"}, "email": {"type": "string"}},
"required": ["name", "age", "email"],
"title": "PersonOutput",
}
server = _make_server(
tools=[
Tool(
name="get_person",
description="Get person data",
input_schema={"type": "object"},
output_schema=output_schema,
)
],
structured_content={"name": "John", "age": 30}, # Missing required 'email'
)
async with Client(server) as client:
with pytest.raises(RuntimeError) as exc_info:
await client.call_tool("get_person", {})
assert "Invalid structured content returned by tool get_person" in str(exc_info.value)
@pytest.mark.anyio
async def test_tool_not_listed_warning(caplog: pytest.LogCaptureFixture):
"""Test that client logs warning when tool is not in list_tools but has output_schema"""
async def on_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[])
async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
return CallToolResult(
content=[TextContent(type="text", text="result")],
structured_content={"result": 42},
)
server = Server("test-server", on_list_tools=on_list_tools, on_call_tool=on_call_tool)
caplog.set_level(logging.WARNING)
async with Client(server) as client:
result = await client.call_tool("mystery_tool", {})
assert result.structured_content == {"result": 42}
assert result.is_error is False
assert "Tool mystery_tool not listed" in caplog.text
+321
View File
@@ -0,0 +1,321 @@
"""Unit tests for the connect-time auto-negotiation policy (`mcp.client._probe.negotiate_auto`).
`negotiate_auto` is a small policy function that drives a `ClientSession` through the
``server/discover`` probe and decides between ``adopt()`` (modern), ``initialize()`` (legacy
fallback), or letting the probe's exception propagate. The policy is a *denylist*: every
``MCPError`` falls back to ``initialize()``, the sole exception being -32022 with a disjoint
modern-only ``supported`` list. Any non-``MCPError`` exception (network errors, anyio
resource errors) propagates untouched — an outage is never an era verdict.
These tests pin the classifier in isolation with a stub session; the end-to-end wire shape is
covered by ``tests/interaction/lowlevel/test_client_connect.py``.
"""
from __future__ import annotations
from typing import Any, cast
import anyio
import httpx
import mcp_types as types
import pytest
from mcp_types import (
INTERNAL_ERROR,
INVALID_REQUEST,
METHOD_NOT_FOUND,
PARSE_ERROR,
REQUEST_TIMEOUT,
UNSUPPORTED_PROTOCOL_VERSION,
Implementation,
ServerCapabilities,
)
from mcp_types.version import (
HANDSHAKE_PROTOCOL_VERSIONS,
LATEST_MODERN_VERSION,
MODERN_PROTOCOL_VERSIONS,
)
from mcp.client._probe import _parse_supported, negotiate_auto
from mcp.client.session import ClientSession
from mcp.shared.exceptions import MCPError
pytestmark = pytest.mark.anyio
class _StubSession:
"""Minimal stand-in for `ClientSession` exposing only what `negotiate_auto` touches.
`send_discover` plays back a script (raise an exception, or return a dict);
`initialize` raises the next entry of an optional `handshake` exception
script (succeeding once it is exhausted) and records its calls; `adopt`
just records.
"""
def __init__(self, *script: dict[str, Any] | Exception, handshake: list[Exception] | None = None) -> None:
self._script: list[dict[str, Any] | Exception] = list(script)
self._handshake: list[Exception] = list(handshake or [])
self.probed_at: list[str] = []
self.initialize_calls: int = 0
self.initialized: bool = False
self.adopted: types.DiscoverResult | None = None
async def send_discover(self, version: str) -> dict[str, Any]:
self.probed_at.append(version)
step = self._script.pop(0)
if isinstance(step, Exception):
raise step
return step
async def initialize(self) -> None:
self.initialize_calls += 1
if self._handshake:
raise self._handshake.pop(0)
self.initialized = True
def adopt(self, result: types.DiscoverResult) -> None:
self.adopted = result
async def _negotiate(session: _StubSession) -> None:
"""Drive `negotiate_auto` against the stub; cast at one seam so the tests stay suppression-free."""
await negotiate_auto(cast("ClientSession", session))
def _discover_dict(versions: list[str] | None = None) -> dict[str, Any]:
return types.DiscoverResult(
supported_versions=versions or list(MODERN_PROTOCOL_VERSIONS),
capabilities=ServerCapabilities(),
server_info=Implementation(name="stub", version="0"),
).model_dump(by_alias=True, mode="json", exclude_none=True)
def _err_32022(supported: Any) -> MCPError:
return MCPError(
code=UNSUPPORTED_PROTOCOL_VERSION,
message="unsupported protocol version",
data={"supported": supported, "requested": LATEST_MODERN_VERSION},
)
# --- happy path: modern server ---
async def test_a_valid_discover_result_is_adopted_without_initializing() -> None:
"""A parseable `DiscoverResult` from the probe is adopted; `initialize()` is never called."""
session = _StubSession(_discover_dict())
await _negotiate(session)
assert session.adopted is not None
assert session.adopted.server_info.name == "stub"
assert not session.initialized
assert session.probed_at == [LATEST_MODERN_VERSION]
async def test_an_unparseable_discover_result_falls_back_to_initialize() -> None:
"""A probe response that does not validate as `DiscoverResult` is not modern evidence,
so the policy falls back to the legacy handshake instead of adopting garbage."""
session = _StubSession({"not": "a discover result"})
await _negotiate(session)
assert session.initialized
assert session.adopted is None
# --- the denylist: every JSON-RPC error code falls back ---
@pytest.mark.parametrize(
"code",
[
pytest.param(METHOD_NOT_FOUND, id="method-not-found-32601"),
pytest.param(INVALID_REQUEST, id="invalid-request-32600"),
pytest.param(INTERNAL_ERROR, id="internal-error-32603"),
pytest.param(PARSE_ERROR, id="parse-error-32700"),
],
)
async def test_any_jsonrpc_error_from_the_probe_falls_back_to_initialize(code: int) -> None:
"""The denylist: every server-sent JSON-RPC error code is treated as "not modern" and
triggers the legacy `initialize()` handshake. Legacy servers reject the unknown
``server/discover`` method with various codes (-32601, -32600, -32603, -32700) depending
on where in their pipeline the request bounces."""
session = _StubSession(MCPError(code=code, message="nope"))
await _negotiate(session)
assert session.initialized
assert session.adopted is None
assert session.probed_at == [LATEST_MODERN_VERSION]
# --- -32022 corrective retry ---
async def test_unsupported_version_with_a_mutual_modern_version_retries_once_then_adopts() -> None:
"""-32022 with a `supported` list naming a modern version we speak: re-probe once at
the highest mutual version, then adopt the second response."""
session = _StubSession(_err_32022(list(MODERN_PROTOCOL_VERSIONS)), _discover_dict())
await _negotiate(session)
assert session.probed_at == [LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS[-1]]
assert session.adopted is not None
assert not session.initialized
async def test_unsupported_version_naming_only_handshake_versions_falls_back_to_initialize() -> None:
"""-32022 with `supported` naming only handshake-era versions: the server is reachable
via the legacy handshake, so fall back rather than raise."""
session = _StubSession(_err_32022(list(HANDSHAKE_PROTOCOL_VERSIONS)))
await _negotiate(session)
assert session.initialized
assert session.adopted is None
assert session.probed_at == [LATEST_MODERN_VERSION]
async def test_unsupported_version_with_disjoint_modern_only_supported_reraises() -> None:
"""-32022 with `supported` naming only modern versions we *don't* speak: this is the
one denylist exception — the server is modern-only and there is no mutual version, so
falling back to `initialize()` would also fail. The original `MCPError` re-raises."""
session = _StubSession(_err_32022(["2099-01-01"]))
with pytest.raises(MCPError) as exc_info:
await _negotiate(session)
assert exc_info.value.code == UNSUPPORTED_PROTOCOL_VERSION
assert not session.initialized
assert session.adopted is None
@pytest.mark.parametrize(
"data",
[
pytest.param(None, id="no-data"),
pytest.param({"supported": "not-a-list"}, id="malformed-supported"),
pytest.param({"requested": LATEST_MODERN_VERSION}, id="missing-supported"),
],
)
async def test_unsupported_version_with_unparseable_data_falls_back_to_initialize(data: Any) -> None:
"""-32022 with no/malformed `error.data`: nothing actionable, so fall through to the
denylist's `initialize()` fallback rather than guess or raise."""
session = _StubSession(MCPError(code=UNSUPPORTED_PROTOCOL_VERSION, message="bad version", data=data))
await _negotiate(session)
assert session.initialized
assert session.adopted is None
assert session.probed_at == [LATEST_MODERN_VERSION]
async def test_a_second_unsupported_version_after_the_corrective_retry_does_not_loop() -> None:
"""The corrective -32022 retry happens at most once; a second -32022 naming a
modern-only `supported` list re-raises rather than re-probing forever (the loop
guard makes this the disjoint-modern case on attempt two)."""
session = _StubSession(_err_32022(list(MODERN_PROTOCOL_VERSIONS)), _err_32022(list(MODERN_PROTOCOL_VERSIONS)))
with pytest.raises(MCPError) as exc_info:
await _negotiate(session)
assert exc_info.value.code == UNSUPPORTED_PROTOCOL_VERSION
assert session.probed_at == [LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS[-1]]
assert not session.initialized
assert session.adopted is None
# --- -32022 from the fallback handshake: modern evidence, one re-probe ---
async def test_handshake_unsupported_after_a_timed_out_probe_reprobes_and_adopts() -> None:
"""A probe that times out client-side but succeeds on a slow-starting
server locks the connection modern, so the fallback handshake answers
-32022. That code is itself modern evidence: re-probe once at a version
the server names and adopt - the connect must not fail."""
session = _StubSession(
MCPError(code=REQUEST_TIMEOUT, message="Request 'server/discover' timed out"),
_discover_dict(),
handshake=[_err_32022(list(MODERN_PROTOCOL_VERSIONS))],
)
await _negotiate(session)
assert session.probed_at == [LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS[-1]]
assert session.adopted is not None
assert session.initialize_calls == 1
assert not session.initialized
@pytest.mark.parametrize(
"data",
[
pytest.param({"supported": ["2099-01-01"], "requested": LATEST_MODERN_VERSION}, id="disjoint"),
pytest.param(None, id="no-data"),
],
)
async def test_handshake_unsupported_without_a_mutual_version_reraises(data: Any) -> None:
"""-32022 from the handshake naming no version we speak (or nothing
parseable) leaves nothing to retry with - the error propagates."""
session = _StubSession(
MCPError(code=METHOD_NOT_FOUND, message="nope"),
handshake=[MCPError(code=UNSUPPORTED_PROTOCOL_VERSION, message="already modern", data=data)],
)
with pytest.raises(MCPError) as exc_info:
await _negotiate(session)
assert exc_info.value.code == UNSUPPORTED_PROTOCOL_VERSION
assert session.adopted is None
assert not session.initialized
async def test_handshake_unsupported_reprobes_at_most_once() -> None:
"""The handshake-driven re-probe is bounded: if the second attempt also
ends in a timed-out probe and a -32022 handshake, the -32022 propagates
instead of looping."""
timeout = MCPError(code=REQUEST_TIMEOUT, message="Request 'server/discover' timed out")
session = _StubSession(
timeout,
timeout,
handshake=[_err_32022(list(MODERN_PROTOCOL_VERSIONS)), _err_32022(list(MODERN_PROTOCOL_VERSIONS))],
)
with pytest.raises(MCPError) as exc_info:
await _negotiate(session)
assert exc_info.value.code == UNSUPPORTED_PROTOCOL_VERSION
assert session.probed_at == [LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS[-1]]
assert session.initialize_calls == 2
async def test_any_other_handshake_error_propagates_unchanged() -> None:
"""A non--32022 error from the fallback handshake is a real handshake
failure, not era evidence - it propagates without a re-probe."""
session = _StubSession(
MCPError(code=METHOD_NOT_FOUND, message="nope"),
handshake=[MCPError(code=INTERNAL_ERROR, message="handshake broke")],
)
with pytest.raises(MCPError) as exc_info:
await _negotiate(session)
assert exc_info.value.code == INTERNAL_ERROR
assert session.probed_at == [LATEST_MODERN_VERSION]
# --- non-MCP errors propagate ---
@pytest.mark.parametrize(
"exc",
[
pytest.param(httpx.ConnectError("connection refused"), id="httpx-connect-error"),
pytest.param(anyio.ClosedResourceError(), id="anyio-closed-resource"),
],
)
async def test_a_network_or_resource_error_from_the_probe_propagates_unchanged(exc: Exception) -> None:
"""Anything that is not an `MCPError` propagates as-is; an outage or in-process bug
is never an era verdict, and `initialize()` is not called."""
session = _StubSession(exc)
with pytest.raises(type(exc)):
await _negotiate(session)
assert not session.initialized
assert session.adopted is None
# --- helper ---
@pytest.mark.parametrize(
("data", "expected"),
[
({"supported": ["2026-07-28"], "requested": "x"}, ["2026-07-28"]),
({"supported": [], "requested": "x"}, []),
(None, None),
({"supported": 123, "requested": "x"}, None),
("not a dict", None),
],
)
def test_parse_supported_returns_none_for_anything_not_shaped_like_the_spec_error_data(
data: Any, expected: list[str] | None
) -> None:
"""`_parse_supported` returns the `supported` list when `error.data` validates as
`UnsupportedProtocolVersionErrorData`, and `None` otherwise — never raises."""
assert _parse_supported(data) == expected
+132
View File
@@ -0,0 +1,132 @@
import pytest
from mcp_types import (
INVALID_REQUEST,
CreateMessageRequestParams,
CreateMessageResult,
CreateMessageResultWithTools,
SamplingMessage,
TextContent,
ToolUseContent,
)
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared.exceptions import MCPError
@pytest.mark.anyio
async def test_sampling_callback():
server = MCPServer("test")
callback_return = CreateMessageResult(
role="assistant",
content=TextContent(type="text", text="This is a response from the sampling callback"),
model="test-model",
stop_reason="endTurn",
)
async def sampling_callback(
context: ClientRequestContext,
params: CreateMessageRequestParams,
) -> CreateMessageResult:
return callback_return
@server.tool("test_sampling")
async def test_sampling_tool(message: str, ctx: Context) -> bool:
value = await ctx.session.create_message( # pyright: ignore[reportDeprecated]
messages=[SamplingMessage(role="user", content=TextContent(type="text", text=message))],
max_tokens=100,
)
assert value == callback_return
return True
# Test with sampling callback
async with Client(server, sampling_callback=sampling_callback, mode="legacy") as client:
# Make a request to trigger sampling callback
result = await client.call_tool("test_sampling", {"message": "Test message for sampling"})
assert result.is_error is False
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "true"
# Without a sampling callback the client responds with an MCPError, which the
# tool body doesn't catch — the wrapper re-raises it as a top-level JSON-RPC
# error rather than wrapping it as an isError result.
async with Client(server, mode="legacy") as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("test_sampling", {"message": "Test message for sampling"})
assert exc_info.value.error.code == INVALID_REQUEST
@pytest.mark.anyio
async def test_create_message_backwards_compat_single_content():
"""Test backwards compatibility: create_message without tools returns single content."""
server = MCPServer("test")
# Callback returns single content (text)
callback_return = CreateMessageResult(
role="assistant",
content=TextContent(type="text", text="Hello from LLM"),
model="test-model",
stop_reason="endTurn",
)
async def sampling_callback(
context: ClientRequestContext,
params: CreateMessageRequestParams,
) -> CreateMessageResult:
return callback_return
@server.tool("test_backwards_compat")
async def test_tool(message: str, ctx: Context) -> bool:
# Call create_message WITHOUT tools
result = await ctx.session.create_message( # pyright: ignore[reportDeprecated]
messages=[SamplingMessage(role="user", content=TextContent(type="text", text=message))],
max_tokens=100,
)
# Backwards compat: result should be CreateMessageResult
assert isinstance(result, CreateMessageResult)
# Content should be single (not a list) - this is the key backwards compat check
assert isinstance(result.content, TextContent)
assert result.content.text == "Hello from LLM"
# CreateMessageResult should NOT have content_as_list (that's on WithTools)
assert not hasattr(result, "content_as_list") or not callable(getattr(result, "content_as_list", None))
return True
async with Client(server, sampling_callback=sampling_callback, mode="legacy") as client:
result = await client.call_tool("test_backwards_compat", {"message": "Test"})
assert result.is_error is False
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "true"
@pytest.mark.anyio
async def test_create_message_result_with_tools_type():
"""Test that CreateMessageResultWithTools supports content_as_list."""
# Test the type itself, not the overload (overload requires client capability setup)
result = CreateMessageResultWithTools(
role="assistant",
content=ToolUseContent(type="tool_use", id="call_123", name="get_weather", input={"city": "SF"}),
model="test-model",
stop_reason="toolUse",
)
# CreateMessageResultWithTools should have content_as_list
content_list = result.content_as_list
assert len(content_list) == 1
assert content_list[0].type == "tool_use"
# It should also work with array content
result_array = CreateMessageResultWithTools(
role="assistant",
content=[
TextContent(type="text", text="Let me check the weather"),
ToolUseContent(type="tool_use", id="call_456", name="get_weather", input={"city": "NYC"}),
],
model="test-model",
stop_reason="toolUse",
)
content_list_array = result_array.content_as_list
assert len(content_list_array) == 2
assert content_list_array[0].type == "text"
assert content_list_array[1].type == "tool_use"
+169
View File
@@ -0,0 +1,169 @@
"""Regression test for issue #1630: OAuth2 scope incorrectly set to resource_metadata URL.
This test verifies that when a 401 response contains both resource_metadata and scope
in the WWW-Authenticate header, the actual scope is used (not the resource_metadata URL).
"""
from unittest import mock
import httpx
import pytest
from pydantic import AnyUrl
from mcp.client.auth import OAuthClientProvider
from mcp.shared.auth import (
AuthorizationCodeResult,
OAuthClientInformationFull,
OAuthClientMetadata,
OAuthToken,
)
class MockTokenStorage:
"""Mock token storage for testing."""
def __init__(self) -> None:
self._tokens: OAuthToken | None = None
self._client_info: OAuthClientInformationFull | None = None
async def get_tokens(self) -> OAuthToken | None:
return self._tokens # pragma: no cover
async def set_tokens(self, tokens: OAuthToken) -> None:
self._tokens = tokens
async def get_client_info(self) -> OAuthClientInformationFull | None:
return self._client_info # pragma: no cover
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
self._client_info = client_info # pragma: no cover
@pytest.mark.anyio
async def test_401_uses_www_auth_scope_not_resource_metadata_url():
"""Regression test for #1630: Ensure scope is extracted from WWW-Authenticate header,
not the resource_metadata URL.
When a 401 response contains:
WWW-Authenticate: Bearer resource_metadata="https://...", scope="read write"
The client should use "read write" as the scope, NOT the resource_metadata URL.
"""
async def redirect_handler(url: str) -> None:
pass # pragma: no cover
async def callback_handler() -> AuthorizationCodeResult:
return AuthorizationCodeResult(code="test_auth_code", state="test_state") # pragma: no cover
client_metadata = OAuthClientMetadata(
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
client_name="Test Client",
)
provider = OAuthClientProvider(
server_url="https://api.example.com/mcp",
client_metadata=client_metadata,
storage=MockTokenStorage(),
redirect_handler=redirect_handler,
callback_handler=callback_handler,
)
provider.context.current_tokens = None
provider.context.token_expiry_time = None
provider._initialized = True
# Pre-set client info to skip DCR
provider.context.client_info = OAuthClientInformationFull(
client_id="test_client",
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
)
test_request = httpx.Request("GET", "https://api.example.com/mcp")
auth_flow = provider.async_auth_flow(test_request)
# First request (no auth header yet)
await auth_flow.__anext__()
# 401 response with BOTH resource_metadata URL and scope in WWW-Authenticate
# This is the key: the bug would use the URL as scope instead of "read write"
resource_metadata_url = "https://api.example.com/.well-known/oauth-protected-resource"
expected_scope = "read write"
response_401 = httpx.Response(
401,
headers={"WWW-Authenticate": (f'Bearer resource_metadata="{resource_metadata_url}", scope="{expected_scope}"')},
request=test_request,
)
# Send 401, expect PRM discovery request
prm_request = await auth_flow.asend(response_401)
assert ".well-known/oauth-protected-resource" in str(prm_request.url)
# PRM response with scopes_supported (these should be overridden by WWW-Auth scope)
prm_response = httpx.Response(
200,
content=(
b'{"resource": "https://api.example.com/mcp", '
b'"authorization_servers": ["https://auth.example.com"], '
b'"scopes_supported": ["fallback:scope1", "fallback:scope2"]}'
),
request=prm_request,
)
# Send PRM response, expect OAuth metadata discovery
oauth_metadata_request = await auth_flow.asend(prm_response)
assert ".well-known/oauth-authorization-server" in str(oauth_metadata_request.url)
# OAuth metadata response
oauth_metadata_response = httpx.Response(
200,
content=(
b'{"issuer": "https://auth.example.com", '
b'"authorization_endpoint": "https://auth.example.com/authorize", '
b'"token_endpoint": "https://auth.example.com/token"}'
),
request=oauth_metadata_request,
)
# Mock authorization to skip interactive flow
provider._perform_authorization_code_grant = mock.AsyncMock(return_value=("test_auth_code", "test_code_verifier"))
# Send OAuth metadata response, expect token request
token_request = await auth_flow.asend(oauth_metadata_response)
assert "token" in str(token_request.url)
# NOW CHECK: The scope should be the WWW-Authenticate scope, NOT the URL
# This is where the bug manifested - scope was set to resource_metadata_url
actual_scope = provider.context.client_metadata.scope
# This assertion would FAIL on main (scope would be the URL)
# but PASS on the fix branch (scope is "read write")
assert actual_scope == expected_scope, (
f"Expected scope to be '{expected_scope}' from WWW-Authenticate header, "
f"but got '{actual_scope}'. "
f"If scope is '{resource_metadata_url}', the bug from #1630 is present."
)
# Verify it's definitely not the URL (explicit check for the bug)
assert actual_scope != resource_metadata_url, (
f"BUG #1630: Scope was incorrectly set to resource_metadata URL '{resource_metadata_url}' "
f"instead of the actual scope '{expected_scope}'"
)
# Complete the flow to properly release the lock
token_response = httpx.Response(
200,
content=b'{"access_token": "test_token", "token_type": "Bearer", "expires_in": 3600}',
request=token_request,
)
final_request = await auth_flow.asend(token_response)
assert final_request.headers["Authorization"] == "Bearer test_token"
# Finish the flow
final_response = httpx.Response(200, request=final_request)
try:
await auth_flow.asend(final_response)
except StopAsyncIteration:
pass
+252
View File
@@ -0,0 +1,252 @@
"""`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, OnNotifyIntercept, 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,
on_notify_intercept: OnNotifyIntercept | None = None,
*,
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)
File diff suppressed because it is too large Load Diff
+469
View File
@@ -0,0 +1,469 @@
"""`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, OnNotifyIntercept, 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,
on_notify_intercept: OnNotifyIntercept | None = None,
*,
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"})
+141
View File
@@ -0,0 +1,141 @@
"""Concurrency over a single client session: multiple requests in flight at once, in both directions."""
import anyio
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CallToolResult,
CreateMessageRequestParams,
CreateMessageResult,
SamplingMessage,
TextContent,
)
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.server.mcpserver import Context, MCPServer
pytestmark = pytest.mark.anyio
async def test_concurrent_tool_calls_resolve_out_of_order_to_their_own_callers() -> None:
"""Three tool calls in flight at once on one session each receive their own result, even though
the responses come back in the reverse of the order the requests were sent.
SDK-defined contract: pins the client request machinery's support for concurrent in-flight
calls with out-of-order response correlation. Each handler parks on its own release event
after signalling it started; a session that serialized requests would never start the later
handlers and the test would time out instead.
"""
send_order = ["a", "b", "c"]
started = {tag: anyio.Event() for tag in send_order}
release = {tag: anyio.Event() for tag in send_order}
done = {tag: anyio.Event() for tag in send_order}
completion_order: list[str] = []
results: dict[str, CallToolResult] = {}
server = MCPServer("parking")
@server.tool()
async def park(tag: str) -> str:
started[tag].set()
await release[tag].wait()
return f"result:{tag}"
async with Client(server) as client:
async def call_and_record(tag: str) -> None:
results[tag] = await client.call_tool("park", {"tag": tag})
completion_order.append(tag)
done[tag].set()
with anyio.fail_after(5):
async with anyio.create_task_group() as task_group: # pragma: no branch
# Waiting for each handler to start before issuing the next call fixes the send
# order, and leaves all three parked in flight together once the loop finishes.
for tag in send_order:
task_group.start_soon(call_and_record, tag)
await started[tag].wait()
# Nothing completed yet: all three calls are genuinely concurrent.
assert completion_order == []
# Release in reverse, awaiting each completion so the finish order is forced.
for tag in reversed(send_order):
release[tag].set()
await done[tag].wait()
assert completion_order == ["c", "b", "a"]
assert results == snapshot(
{
"c": CallToolResult(content=[TextContent(text="result:c")], structured_content={"result": "result:c"}),
"b": CallToolResult(content=[TextContent(text="result:b")], structured_content={"result": "result:b"}),
"a": CallToolResult(content=[TextContent(text="result:a")], structured_content={"result": "result:a"}),
}
)
async def test_overlapping_sampling_requests_are_serviced_concurrently_by_the_client() -> None:
"""A server tool that fans out two sampling requests at once gets both echoes back: the client
runs overlapping inbound `create_message` requests concurrently instead of serializing them in
its receive loop.
Regression pin for https://github.com/modelcontextprotocol/python-sdk/issues/2489 -- v1's
`BaseSession` awaited each inbound request handler inline, so the second sampling callback
could not start until the first returned; here both rendezvous before either is released.
"""
sampling_started = {"x": anyio.Event(), "y": anyio.Event()}
sampling_release = anyio.Event()
tool_results: list[CallToolResult] = []
server = MCPServer("fan_out_server")
@server.tool()
async def fan_out(ctx: Context) -> str:
echoes: dict[str, str] = {}
async def sample(tag: str) -> None:
result = await ctx.session.create_message( # pyright: ignore[reportDeprecated]
messages=[SamplingMessage(role="user", content=TextContent(text=tag))],
max_tokens=10,
)
assert isinstance(result.content, TextContent)
echoes[tag] = result.content.text
async with anyio.create_task_group() as sampler_group:
sampler_group.start_soon(sample, "x")
sampler_group.start_soon(sample, "y")
return f"{echoes['x']} {echoes['y']}"
async def sampling_callback(
context: ClientRequestContext, params: CreateMessageRequestParams
) -> CreateMessageResult:
content = params.messages[0].content
assert isinstance(content, TextContent)
sampling_started[content.text].set()
await sampling_release.wait()
return CreateMessageResult(
role="assistant",
content=TextContent(text=f"echo:{content.text}"),
model="test-model",
stop_reason="endTurn",
)
async with Client(server, sampling_callback=sampling_callback, mode="legacy") as client:
with anyio.fail_after(5):
async with anyio.create_task_group() as task_group: # pragma: no branch
async def invoke_fan_out() -> None:
tool_results.append(await client.call_tool("fan_out", {}))
task_group.start_soon(invoke_fan_out)
# Both sampling callbacks are mid-flight before either may answer -- a client that
# serialized inbound requests would never start the second one.
await sampling_started["x"].wait()
await sampling_started["y"].wait()
sampling_release.set()
assert tool_results == snapshot(
[CallToolResult(content=[TextContent(text="echo:x echo:y")], structured_content={"result": "echo:x echo:y"})]
)
+404
View File
@@ -0,0 +1,404 @@
import contextlib
from unittest import mock
import httpx
import mcp_types as types
import pytest
import mcp
from mcp.client.session_group import (
ClientSessionGroup,
ClientSessionParameters,
SseServerParameters,
StreamableHttpParameters,
)
from mcp.client.stdio import StdioServerParameters
from mcp.shared.exceptions import MCPError
@pytest.fixture
def mock_exit_stack():
"""Fixture for a mocked AsyncExitStack."""
# Use unittest.mock.Mock directly if needed, or just a plain object
# if only attribute access/existence is needed.
# For AsyncExitStack, Mock or MagicMock is usually fine.
return mock.MagicMock(spec=contextlib.AsyncExitStack)
def test_client_session_group_init():
mcp_session_group = ClientSessionGroup()
assert not mcp_session_group._tools
assert not mcp_session_group._resources
assert not mcp_session_group._prompts
assert not mcp_session_group._tool_to_session
def test_client_session_group_component_properties():
# --- Mock Dependencies ---
mock_prompt = mock.Mock()
mock_resource = mock.Mock()
mock_tool = mock.Mock()
# --- Prepare Session Group ---
mcp_session_group = ClientSessionGroup()
mcp_session_group._prompts = {"my_prompt": mock_prompt}
mcp_session_group._resources = {"my_resource": mock_resource}
mcp_session_group._tools = {"my_tool": mock_tool}
# --- Assertions ---
assert mcp_session_group.prompts == {"my_prompt": mock_prompt}
assert mcp_session_group.resources == {"my_resource": mock_resource}
assert mcp_session_group.tools == {"my_tool": mock_tool}
@pytest.mark.anyio
async def test_client_session_group_call_tool():
# --- Mock Dependencies ---
mock_session = mock.AsyncMock()
# --- Prepare Session Group ---
def hook(name: str, server_info: types.Implementation) -> str: # pragma: no cover
return f"{(server_info.name)}-{name}"
mcp_session_group = ClientSessionGroup(component_name_hook=hook)
mcp_session_group._tools = {"server1-my_tool": types.Tool(name="my_tool", input_schema={})}
mcp_session_group._tool_to_session = {"server1-my_tool": mock_session}
text_content = types.TextContent(type="text", text="OK")
mock_session.call_tool.return_value = types.CallToolResult(content=[text_content])
# --- Test Execution ---
result = await mcp_session_group.call_tool(
name="server1-my_tool",
arguments={
"name": "value1",
"args": {},
},
)
# --- Assertions ---
assert result.content == [text_content]
mock_session.call_tool.assert_called_once_with(
"my_tool",
arguments={"name": "value1", "args": {}},
read_timeout_seconds=None,
progress_callback=None,
input_responses=None,
request_state=None,
meta=None,
allow_input_required=False,
)
@pytest.mark.anyio
async def test_client_session_group_call_tool_forwards_allow_input_required():
mock_session = mock.AsyncMock()
mcp_session_group = ClientSessionGroup()
mcp_session_group._tools = {"my_tool": types.Tool(name="my_tool", input_schema={})}
mcp_session_group._tool_to_session = {"my_tool": mock_session}
mock_session.call_tool.return_value = types.InputRequiredResult(request_state="s")
result = await mcp_session_group.call_tool(name="my_tool", arguments={}, allow_input_required=True)
assert isinstance(result, types.InputRequiredResult)
assert result.request_state == "s"
assert mock_session.call_tool.call_args.kwargs["allow_input_required"] is True
@pytest.mark.anyio
async def test_client_session_group_connect_to_server(mock_exit_stack: contextlib.AsyncExitStack):
"""Test connecting to a server and aggregating components."""
# --- Mock Dependencies ---
mock_server_info = mock.Mock(spec=types.Implementation)
mock_server_info.name = "TestServer1"
mock_session = mock.AsyncMock(spec=mcp.ClientSession)
mock_tool1 = mock.Mock(spec=types.Tool)
mock_tool1.name = "tool_a"
mock_resource1 = mock.Mock(spec=types.Resource)
mock_resource1.name = "resource_b"
mock_prompt1 = mock.Mock(spec=types.Prompt)
mock_prompt1.name = "prompt_c"
mock_session.list_tools.return_value = mock.AsyncMock(tools=[mock_tool1])
mock_session.list_resources.return_value = mock.AsyncMock(resources=[mock_resource1])
mock_session.list_prompts.return_value = mock.AsyncMock(prompts=[mock_prompt1])
# --- Test Execution ---
group = ClientSessionGroup(exit_stack=mock_exit_stack)
with mock.patch.object(group, "_establish_session", return_value=(mock_server_info, mock_session)):
await group.connect_to_server(StdioServerParameters(command="test"))
# --- Assertions ---
assert mock_session in group._sessions
assert len(group.tools) == 1
assert "tool_a" in group.tools
assert group.tools["tool_a"] == mock_tool1
assert group._tool_to_session["tool_a"] == mock_session
assert len(group.resources) == 1
assert "resource_b" in group.resources
assert group.resources["resource_b"] == mock_resource1
assert len(group.prompts) == 1
assert "prompt_c" in group.prompts
assert group.prompts["prompt_c"] == mock_prompt1
mock_session.list_tools.assert_awaited_once()
mock_session.list_resources.assert_awaited_once()
mock_session.list_prompts.assert_awaited_once()
@pytest.mark.anyio
async def test_client_session_group_connect_to_server_with_name_hook(mock_exit_stack: contextlib.AsyncExitStack):
"""Test connecting with a component name hook."""
# --- Mock Dependencies ---
mock_server_info = mock.Mock(spec=types.Implementation)
mock_server_info.name = "HookServer"
mock_session = mock.AsyncMock(spec=mcp.ClientSession)
mock_tool = mock.Mock(spec=types.Tool)
mock_tool.name = "base_tool"
mock_session.list_tools.return_value = mock.AsyncMock(tools=[mock_tool])
mock_session.list_resources.return_value = mock.AsyncMock(resources=[])
mock_session.list_prompts.return_value = mock.AsyncMock(prompts=[])
# --- Test Setup ---
def name_hook(name: str, server_info: types.Implementation) -> str:
return f"{server_info.name}.{name}"
# --- Test Execution ---
group = ClientSessionGroup(exit_stack=mock_exit_stack, component_name_hook=name_hook)
with mock.patch.object(group, "_establish_session", return_value=(mock_server_info, mock_session)):
await group.connect_to_server(StdioServerParameters(command="test"))
# --- Assertions ---
assert mock_session in group._sessions
assert len(group.tools) == 1
expected_tool_name = "HookServer.base_tool"
assert expected_tool_name in group.tools
assert group.tools[expected_tool_name] == mock_tool
assert group._tool_to_session[expected_tool_name] == mock_session
@pytest.mark.anyio
async def test_client_session_group_disconnect_from_server():
"""Test disconnecting from a server."""
# --- Test Setup ---
group = ClientSessionGroup()
server_name = "ServerToDisconnect"
# Manually populate state using standard mocks
mock_session1 = mock.MagicMock(spec=mcp.ClientSession)
mock_session2 = mock.MagicMock(spec=mcp.ClientSession)
mock_tool1 = mock.Mock(spec=types.Tool)
mock_tool1.name = "tool1"
mock_resource1 = mock.Mock(spec=types.Resource)
mock_resource1.name = "res1"
mock_prompt1 = mock.Mock(spec=types.Prompt)
mock_prompt1.name = "prm1"
mock_tool2 = mock.Mock(spec=types.Tool)
mock_tool2.name = "tool2"
mock_component_named_like_server = mock.Mock()
mock_session = mock.Mock(spec=mcp.ClientSession)
group._tools = {
"tool1": mock_tool1,
"tool2": mock_tool2,
server_name: mock_component_named_like_server,
}
group._tool_to_session = {
"tool1": mock_session1,
"tool2": mock_session2,
server_name: mock_session1,
}
group._resources = {
"res1": mock_resource1,
server_name: mock_component_named_like_server,
}
group._prompts = {
"prm1": mock_prompt1,
server_name: mock_component_named_like_server,
}
group._sessions = {
mock_session: ClientSessionGroup._ComponentNames(
prompts=set({"prm1"}),
resources=set({"res1"}),
tools=set({"tool1", "tool2"}),
)
}
# --- Assertions ---
assert mock_session in group._sessions
assert "tool1" in group._tools
assert "tool2" in group._tools
assert "res1" in group._resources
assert "prm1" in group._prompts
# --- Test Execution ---
await group.disconnect_from_server(mock_session)
# --- Assertions ---
assert mock_session not in group._sessions
assert "tool1" not in group._tools
assert "tool2" not in group._tools
assert "res1" not in group._resources
assert "prm1" not in group._prompts
@pytest.mark.anyio
async def test_client_session_group_connect_to_server_duplicate_tool_raises_error(
mock_exit_stack: contextlib.AsyncExitStack,
):
"""Test MCPError raised when connecting a server with a dup name."""
# --- Setup Pre-existing State ---
group = ClientSessionGroup(exit_stack=mock_exit_stack)
existing_tool_name = "shared_tool"
# Manually add a tool to simulate a previous connection
group._tools[existing_tool_name] = mock.Mock(spec=types.Tool)
group._tools[existing_tool_name].name = existing_tool_name
# Need a dummy session associated with the existing tool
mock_session = mock.MagicMock(spec=mcp.ClientSession)
group._tool_to_session[existing_tool_name] = mock_session
group._session_exit_stacks[mock_session] = mock.Mock(spec=contextlib.AsyncExitStack)
# --- Mock New Connection Attempt ---
mock_server_info_new = mock.Mock(spec=types.Implementation)
mock_server_info_new.name = "ServerWithDuplicate"
mock_session_new = mock.AsyncMock(spec=mcp.ClientSession)
# Configure the new session to return a tool with the *same name*
duplicate_tool = mock.Mock(spec=types.Tool)
duplicate_tool.name = existing_tool_name
mock_session_new.list_tools.return_value = mock.AsyncMock(tools=[duplicate_tool])
# Keep other lists empty for simplicity
mock_session_new.list_resources.return_value = mock.AsyncMock(resources=[])
mock_session_new.list_prompts.return_value = mock.AsyncMock(prompts=[])
# --- Test Execution and Assertion ---
with pytest.raises(MCPError) as excinfo:
with mock.patch.object(
group,
"_establish_session",
return_value=(mock_server_info_new, mock_session_new),
):
await group.connect_to_server(StdioServerParameters(command="test"))
# Assert details about the raised error
assert excinfo.value.error.code == types.INVALID_PARAMS
assert existing_tool_name in excinfo.value.error.message
assert "already exist " in excinfo.value.error.message
# Verify the duplicate tool was *not* added again (state should be unchanged)
assert len(group._tools) == 1 # Should still only have the original
assert group._tools[existing_tool_name] is not duplicate_tool # Ensure it's the original mock
@pytest.mark.anyio
async def test_client_session_group_disconnect_non_existent_server():
"""Test disconnecting a server that isn't connected."""
session = mock.Mock(spec=mcp.ClientSession)
group = ClientSessionGroup()
with pytest.raises(MCPError):
await group.disconnect_from_server(session)
# TODO(Marcelo): This is horrible. We should drop this test.
@pytest.mark.anyio
@pytest.mark.parametrize(
"server_params_instance, client_type_name, patch_target_for_client_func",
[
(
StdioServerParameters(command="test_stdio_cmd"),
"stdio",
"mcp.client.session_group.mcp.stdio_client",
),
(
SseServerParameters(url="http://test.com/sse", timeout=10.0),
"sse",
"mcp.client.session_group.sse_client",
), # url, headers, timeout, sse_read_timeout
(
StreamableHttpParameters(url="http://test.com/stream", terminate_on_close=False),
"streamablehttp",
"mcp.client.session_group.streamable_http_client",
), # url, headers, timeout, sse_read_timeout, terminate_on_close
],
)
async def test_client_session_group_establish_session_parameterized(
server_params_instance: StdioServerParameters | SseServerParameters | StreamableHttpParameters,
client_type_name: str, # Just for clarity or conditional logic if needed
patch_target_for_client_func: str,
):
with mock.patch("mcp.client.session_group.mcp.ClientSession") as mock_ClientSession_class:
with mock.patch(patch_target_for_client_func) as mock_specific_client_func:
mock_client_cm_instance = mock.AsyncMock(name=f"{client_type_name}ClientCM")
mock_read_stream = mock.AsyncMock(name=f"{client_type_name}Read")
mock_write_stream = mock.AsyncMock(name=f"{client_type_name}Write")
# All client context managers return (read_stream, write_stream)
mock_client_cm_instance.__aenter__.return_value = (mock_read_stream, mock_write_stream)
mock_client_cm_instance.__aexit__ = mock.AsyncMock(return_value=None)
mock_specific_client_func.return_value = mock_client_cm_instance
# --- Mock mcp.ClientSession (class) ---
# mock_ClientSession_class is already provided by the outer patch
mock_raw_session_cm = mock.AsyncMock(name="RawSessionCM")
mock_ClientSession_class.return_value = mock_raw_session_cm
mock_entered_session = mock.AsyncMock(name="EnteredSessionInstance")
mock_raw_session_cm.__aenter__.return_value = mock_entered_session
mock_raw_session_cm.__aexit__ = mock.AsyncMock(return_value=None)
# Mock session.initialize()
mock_initialize_result = mock.AsyncMock(name="InitializeResult")
mock_initialize_result.server_info = types.Implementation(name="foo", version="1")
mock_entered_session.initialize.return_value = mock_initialize_result
# --- Test Execution ---
group = ClientSessionGroup()
returned_server_info = None
returned_session = None
async with contextlib.AsyncExitStack() as stack:
group._exit_stack = stack
(
returned_server_info,
returned_session,
) = await group._establish_session(server_params_instance, ClientSessionParameters())
# --- Assertions ---
# 1. Assert the correct specific client function was called
if client_type_name == "stdio":
assert isinstance(server_params_instance, StdioServerParameters)
mock_specific_client_func.assert_called_once_with(server_params_instance)
elif client_type_name == "sse":
assert isinstance(server_params_instance, SseServerParameters)
mock_specific_client_func.assert_called_once_with(
url=server_params_instance.url,
headers=server_params_instance.headers,
timeout=server_params_instance.timeout,
sse_read_timeout=server_params_instance.sse_read_timeout,
)
elif client_type_name == "streamablehttp": # pragma: no branch
assert isinstance(server_params_instance, StreamableHttpParameters)
# Verify streamable_http_client was called with url, httpx_client, and terminate_on_close
# The http_client is created by the real create_mcp_http_client
call_args = mock_specific_client_func.call_args
assert call_args.kwargs["url"] == server_params_instance.url
assert call_args.kwargs["terminate_on_close"] == server_params_instance.terminate_on_close
assert isinstance(call_args.kwargs["http_client"], httpx.AsyncClient)
mock_client_cm_instance.__aenter__.assert_awaited_once()
# 2. Assert ClientSession was called correctly
mock_ClientSession_class.assert_called_once_with(
mock_read_stream,
mock_write_stream,
read_timeout_seconds=None,
sampling_callback=None,
elicitation_callback=None,
list_roots_callback=None,
logging_callback=None,
message_handler=None,
client_info=None,
)
mock_raw_session_cm.__aenter__.assert_awaited_once()
mock_entered_session.initialize.assert_awaited_once()
# 3. Assert returned values
assert returned_server_info is mock_initialize_result.server_info
assert returned_session is mock_entered_session
@@ -0,0 +1,287 @@
"""`ClientSession` notification bindings: serialized per-binding delivery through a
bounded FIFO, consulted only for methods the negotiated version's core tables do
not know."""
import logging
import anyio
import mcp_types as types
import pytest
from mcp_types import EmptyResult, Implementation, ServerCapabilities
from mcp_types.version import LATEST_MODERN_VERSION
from pydantic import BaseModel
from mcp.client.extension import NotificationBinding
from mcp.client.session import _NOTIFICATION_QUEUE_SIZE, ClientSession
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
from mcp.shared.dispatcher import DispatchContext
from mcp.shared.transport_context import TransportContext
_VENDOR_METHOD = "notifications/vendor/task_done"
class _EventParams(BaseModel):
seq: int
async def _server_on_request(
ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None
) -> dict[str, object]:
assert method == "ping"
return {}
async def _server_on_notify(
ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None
) -> None:
raise NotImplementedError
def _adopt_modern(session: ClientSession) -> None:
session.adopt(
types.DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="stub", version="0"),
)
)
async def _noop_handler(params: _EventParams) -> None:
raise NotImplementedError # construction-only tests never deliver
def test_duplicate_binding_method_rejected() -> None:
"""SDK-defined: two bindings on one wire method cannot be routed apart, so construction fails."""
client_side, _ = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=_noop_handler)
with pytest.raises(ValueError) as exc_info:
ClientSession(dispatcher=client_side, notification_bindings=[binding, binding])
assert str(exc_info.value) == "duplicate notification binding for method 'notifications/vendor/task_done'"
@pytest.mark.anyio
async def test_bound_vendor_notifications_are_delivered_in_order() -> None:
"""SDK-defined: one consumer per binding delivers events in the order the server sent them."""
delivered: list[int] = []
done = anyio.Event()
async def on_event(params: _EventParams) -> None:
delivered.append(params.seq)
if params.seq == 3:
done.set()
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
for seq in (1, 2, 3):
await server_side.notify(_VENDOR_METHOD, {"seq": seq})
await done.wait()
server_side.close()
assert delivered == [1, 2, 3]
@pytest.mark.anyio
async def test_binding_handler_may_do_session_io_without_deadlock() -> None:
"""SDK-defined: delivery is spawn-decoupled, so a handler may await session I/O without deadlock."""
pongs: list[EmptyResult] = []
done = anyio.Event()
client_side, server_side = create_direct_dispatcher_pair()
async def on_event(params: _EventParams) -> None:
pongs.append(await session.send_ping())
done.set()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
await server_side.notify(_VENDOR_METHOD, {"seq": 1})
await done.wait()
server_side.close()
assert pongs == [EmptyResult()]
@pytest.mark.anyio
async def test_overflow_drops_oldest_event_with_a_warning(caplog: pytest.LogCaptureFixture) -> None:
"""SDK-defined: on overflow the bounded FIFO drops the oldest queued event with a
warning; everything still queued delivers in order."""
delivered: list[int] = []
consumer_blocked = anyio.Event()
gate = anyio.Event()
done = anyio.Event()
last_seq = _NOTIFICATION_QUEUE_SIZE + 1
async def on_event(params: _EventParams) -> None:
delivered.append(params.seq)
if params.seq == 0:
consumer_blocked.set()
await gate.wait()
if params.seq == last_seq:
done.set()
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
await server_side.notify(_VENDOR_METHOD, {"seq": 0})
await consumer_blocked.wait()
for seq in range(1, last_seq + 1):
await server_side.notify(_VENDOR_METHOD, {"seq": seq})
gate.set()
await done.wait()
server_side.close()
assert delivered == [0, *range(2, last_seq + 1)]
assert caplog.text.count(f"notification queue for {_VENDOR_METHOD!r} is full") == 1
@pytest.mark.anyio
async def test_invalid_params_are_warned_and_dropped_without_reaching_handler(
caplog: pytest.LogCaptureFixture,
) -> None:
"""SDK-defined: params failing the binding's model are warned and dropped; later valid events deliver."""
delivered: list[int] = []
done = anyio.Event()
async def on_event(params: _EventParams) -> None:
delivered.append(params.seq)
done.set()
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
await server_side.notify(_VENDOR_METHOD, {"bogus": "no seq"})
await server_side.notify(_VENDOR_METHOD, {"seq": 1})
await done.wait()
server_side.close()
assert delivered == [1]
assert f"Failed to validate notification: {_VENDOR_METHOD}" in caplog.text
@pytest.mark.anyio
async def test_unbound_vendor_notification_keeps_the_debug_drop(caplog: pytest.LogCaptureFixture) -> None:
"""SDK-defined: a vendor method with no binding keeps the debug-log-and-drop behaviour."""
caplog.set_level(logging.DEBUG, logger="client")
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=_noop_handler)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
await server_side.notify("notifications/vendor/unbound", {"seq": 1})
server_side.close()
assert f"dropped 'notifications/vendor/unbound': not defined at {LATEST_MODERN_VERSION}" in caplog.text
@pytest.mark.anyio
async def test_core_known_method_never_reaches_binding_and_warns_once_at_adopt(
caplog: pytest.LogCaptureFixture,
) -> None:
"""SDK-defined: a binding for a core-known method never fires and warns once at
adopt(); the typed callback still runs."""
logged: list[types.LoggingMessageNotificationParams] = []
async def logging_callback(params: types.LoggingMessageNotificationParams) -> None:
logged.append(params)
async def on_message(params: BaseModel) -> None:
raise NotImplementedError # structurally unreachable: core parses the method first
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method="notifications/message", params_type=BaseModel, handler=on_message)
session = ClientSession(dispatcher=client_side, logging_callback=logging_callback, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
# In-process notify() awaits _on_notify inline, so the typed callback has already run.
await server_side.notify("notifications/message", {"level": "info", "data": "hello"})
server_side.close()
assert [params.data for params in logged] == ["hello"]
# The bound handler never ran; a delivery would have logged its NotImplementedError.
assert "notification binding handler" not in caplog.text
expected = f"notification binding for 'notifications/message' will never fire at {LATEST_MODERN_VERSION}"
assert caplog.text.count(expected) == 1
@pytest.mark.anyio
async def test_handler_exception_is_contained_and_later_events_deliver(caplog: pytest.LogCaptureFixture) -> None:
"""SDK-defined: a raising handler costs only that delivery; later events still deliver."""
delivered: list[int] = []
done = anyio.Event()
async def on_event(params: _EventParams) -> None:
if params.seq == 1:
raise ValueError("handler boom")
delivered.append(params.seq)
done.set()
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
_adopt_modern(session)
await server_side.notify(_VENDOR_METHOD, {"seq": 1})
await server_side.notify(_VENDOR_METHOD, {"seq": 2})
await done.wait()
server_side.close()
assert delivered == [2]
assert f"notification binding handler for {_VENDOR_METHOD!r} raised" in caplog.text
@pytest.mark.anyio
async def test_binding_delivery_works_without_adopt() -> None:
"""SDK-defined: bindings deliver pre-handshake, under the default version tables."""
delivered: list[int] = []
done = anyio.Event()
async def on_event(params: _EventParams) -> None:
delivered.append(params.seq)
done.set()
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
await tg.start(server_side.run, _server_on_request, _server_on_notify)
async with session:
await server_side.notify(_VENDOR_METHOD, {"seq": 7})
await done.wait()
server_side.close()
assert delivered == [7]
+66
View File
@@ -0,0 +1,66 @@
"""`dispatch_input_request` and `validate_tool_result` are public `ClientSession` API."""
import mcp_types as types
import pytest
from mcp_types import (
CallToolResult,
ErrorData,
ListRootsResult,
ListToolsResult,
PaginatedRequestParams,
Tool,
)
from mcp.client.client import Client
from mcp.client.session import ClientRequestContext, ClientSession
from mcp.server import Server, ServerRequestContext
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
@pytest.mark.anyio
async def test_dispatch_input_request_routes_through_the_callback_table() -> None:
expected = ListRootsResult(roots=[])
async def list_roots(context: ClientRequestContext) -> ListRootsResult:
return expected
client_side, _server_side = create_direct_dispatcher_pair()
session = ClientSession(dispatcher=client_side, list_roots_callback=list_roots)
ctx = ClientRequestContext(session=session, request_id="r-1")
response = await session.dispatch_input_request(ctx, types.ListRootsRequest())
assert response is expected
@pytest.mark.anyio
async def test_dispatch_input_request_returns_error_data_on_refusal() -> None:
"""With no callback registered, refusal comes back as `ErrorData`, not a raise."""
client_side, _server_side = create_direct_dispatcher_pair()
session = ClientSession(dispatcher=client_side)
ctx = ClientRequestContext(session=session, request_id="r-1")
response = await session.dispatch_input_request(ctx, types.ListRootsRequest())
assert isinstance(response, ErrorData)
assert response.code == types.INVALID_REQUEST
def _make_server(output_schema: dict[str, object]) -> Server:
async def on_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"}, output_schema=output_schema)])
return Server("test-server", on_list_tools=on_list_tools)
@pytest.mark.anyio
async def test_validate_tool_result_passes_a_conforming_result() -> None:
server = _make_server({"type": "object", "properties": {"x": {"type": "integer"}}, "required": ["x"]})
async with Client(server) as client:
# The session fetches the listing itself when the tool isn't cached yet.
await client.session.validate_tool_result("t", CallToolResult(content=[], structured_content={"x": 1}))
@pytest.mark.anyio
async def test_validate_tool_result_raises_on_schema_mismatch() -> None:
server = _make_server({"type": "object", "properties": {"x": {"type": "integer"}}, "required": ["x"]})
async with Client(server) as client:
# Stable SDK prefix only: the message tail is jsonschema text that shifts with the dependency.
with pytest.raises(RuntimeError, match="Invalid structured content returned by tool t"):
await client.session.validate_tool_result("t", CallToolResult(content=[], structured_content={"x": "no"}))
File diff suppressed because it is too large Load Diff
+750
View File
@@ -0,0 +1,750 @@
"""Unit tests for the streamable-HTTP client transport.
The full client<->server round trip is pinned by the interaction suite under
tests/interaction/transports/; these tests cover the transport's header encoding and the
per-message metadata-headers merge directly because the headers are an HTTP-seam observation
the public client never exposes.
"""
import base64
import json
from collections.abc import AsyncIterator, Callable, Mapping
from typing import Any
import anyio
import httpx
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
CONNECTION_CLOSED,
INVALID_REQUEST,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
JSONRPCError,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
)
from mcp_types.version import LATEST_MODERN_VERSION
from starlette.types import Receive, Scope, Send
from mcp.client.streamable_http import (
MAX_RECONNECTION_ATTEMPTS,
RequestContext,
StreamableHTTPTransport,
streamable_http_client,
)
from mcp.server import Server
from mcp.server._streamable_http_modern import handle_modern_request
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, ServerEvent
from mcp.shared._context_streams import ContextSendStream, create_context_streams
from mcp.shared.dispatcher import CallOptions, DispatchContext
from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_PROTOCOL_VERSION_HEADER, encode_header_value
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, SessionMessage
from mcp.shared.transport_context import TransportContext
from tests.interaction.transports import StreamingASGITransport
from tests.shared.test_dispatcher import Recorder, echo_handlers
@pytest.mark.parametrize(
("raw", "expected", "wrapped"),
[
("add", snapshot("add"), False),
("", snapshot(""), False),
("tool with spaces", snapshot("tool with spaces"), False),
(" add", snapshot("=?base64?IGFkZA==?="), True),
("add ", snapshot("=?base64?YWRkIA==?="), True),
("résumé", snapshot("=?base64?csOpc3Vtw6k=?="), True),
("a\r\nb", snapshot("=?base64?YQ0KYg==?="), True),
("=?base64?Zm9v?=", snapshot("=?base64?PT9iYXNlNjQ/Wm05dj89?="), True),
],
)
def test_mcp_name_header_values_are_base64_wrapped_when_unsafe_for_an_http_field(
raw: str, expected: str, wrapped: bool
) -> None:
"""Printable-ASCII names pass verbatim; CR/LF, non-ASCII, edge-whitespace, and sentinel-shaped names are wrapped.
The ``=?base64?...?=`` sentinel is the spec's RFC 7230 safety gate for the ``Mcp-Name`` header.
Wrapped values round-trip through base64 so the server can recover the original name. A leading
or trailing space is wrapped because RFC 7230 forbids it in field-values (h11 rejects on real
transports); an empty value is allowed and passes verbatim.
"""
encoded = encode_header_value(raw)
assert encoded == expected
if wrapped:
assert encoded.startswith("=?base64?") and encoded.endswith("?=")
assert base64.b64decode(encoded.removeprefix("=?base64?").removesuffix("?=")).decode() == raw
else:
assert encoded == raw
@pytest.mark.anyio
async def test_post_request_merges_per_message_metadata_headers() -> None:
"""`ClientMessageMetadata.headers` on a `SessionMessage` are merged into the outgoing POST headers
(SDK-defined: the headers sidecar is the path the session uses to reach the transport)."""
recorded: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
recorded.append(request)
body = json.loads(request.content)
return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(
SessionMessage(
message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/list", params={}),
metadata=ClientMessageMetadata(headers={"x-test": "v"}),
)
)
reply = await read.receive()
assert isinstance(reply, SessionMessage)
assert [r.method for r in recorded] == ["POST"]
assert recorded[0].headers["x-test"] == "v"
@pytest.mark.anyio
async def test_pre_session_bare_404_maps_to_method_not_found() -> None:
"""A bare HTTP 404 (no JSON-RPC body) before any session-id is held maps to METHOD_NOT_FOUND.
Gateways and legacy servers 404 at the HTTP layer for unknown methods; with no session yet,
"Session terminated" is meaningless, and the discover→initialize fallback ladder keys on -32601.
"""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(404)
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="server/discover", params={})))
reply = await read.receive()
assert isinstance(reply, SessionMessage)
assert isinstance(reply.message, JSONRPCError)
assert reply.message.error.code == METHOD_NOT_FOUND
@pytest.mark.anyio
async def test_initialize_post_clears_cached_pv_header_and_unstamped_posts_read_it() -> None:
"""``initialize`` discards the cached protocol-version header; every other POST reads it.
Steps:
1. A stamped probe POST caches its ``MCP-Protocol-Version`` header.
2. An ``initialize`` POST clears that cache before building headers, so the fallback
handshake never carries a probe-stamped value.
3. A subsequent stamped POST re-seeds the cache with the negotiated version.
4. An unstamped POST (a JSON-RPC response written by the dispatcher, which never
passes through the session's stamp) then reads the cache and carries the
negotiated version — the spec MUST for all post-initialization HTTP requests.
"""
recorded: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
recorded.append(request)
body = json.loads(request.content)
if "id" not in body or "result" in body:
return httpx.Response(202)
return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(
SessionMessage(
message=JSONRPCRequest(jsonrpc="2.0", id=1, method="server/discover", params={}),
metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: "2026-07-28"}),
)
)
await read.receive()
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=2, method="initialize", params={})))
await read.receive()
await write.send(
SessionMessage(
message=JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"),
metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: "2025-11-25"}),
)
)
# An unstamped JSON-RPC response — what the dispatcher writes when answering
# a server-initiated request (sampling/elicitation/roots).
await write.send(SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=99, result={})))
assert [r.method for r in recorded] == ["POST", "POST", "POST", "POST"]
assert recorded[0].headers[MCP_PROTOCOL_VERSION_HEADER] == "2026-07-28"
assert MCP_PROTOCOL_VERSION_HEADER not in recorded[1].headers
assert recorded[2].headers[MCP_PROTOCOL_VERSION_HEADER] == "2025-11-25"
assert recorded[3].headers[MCP_PROTOCOL_VERSION_HEADER] == "2025-11-25"
class _ParkedSSEStream(httpx.AsyncByteStream):
"""An SSE response body that emits one comment line, then parks until closed.
`opened` fires once the transport is iterating the body (the POST is truly in
flight); `closed` fires when httpx tears the body down — the observable proof
that an abort, not a response, ended the stream.
"""
def __init__(self) -> None:
self.opened = anyio.Event()
self.closed = anyio.Event()
self._release = anyio.Event()
async def __aiter__(self) -> AsyncIterator[bytes]:
self.opened.set()
yield b": parked\n\n"
await self._release.wait()
async def aclose(self) -> None:
self.closed.set()
self._release.set()
def _sse_or_ack_handler(
parked: _ParkedSSEStream, posted: list[dict[str, Any]], frame_posted: anyio.Event
) -> Callable[[httpx.Request], httpx.Response]:
"""Requests get the parked SSE body; notifications get 202 and set `frame_posted`."""
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
posted.append(body)
if "id" in body:
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked)
frame_posted.set()
return httpx.Response(202)
return handler
@pytest.mark.anyio
async def test_modern_cancelled_frame_aborts_the_matching_in_flight_post() -> None:
"""At 2026 an outbound `notifications/cancelled` never POSTs — closing the named
request's response stream IS the wire's cancellation signal — so the transport
aborts the in-flight POST and swallows the frame."""
parked = _ParkedSSEStream()
posted: list[dict[str, Any]] = []
def handler(request: httpx.Request) -> httpx.Response:
posted.append(json.loads(request.content))
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked)
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (_read, write),
):
await write.send(
SessionMessage(
message=JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={}),
metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}),
)
)
await parked.opened.wait()
await write.send(
SessionMessage(
JSONRPCNotification(
jsonrpc="2.0", method="notifications/cancelled", params={"requestId": "listen-1"}
)
)
)
await parked.closed.wait()
assert [body["method"] for body in posted] == ["subscriptions/listen"]
@pytest.mark.anyio
@pytest.mark.parametrize("stamped_version", [None, "2025-11-25"], ids=["no-version-yet", "2025-11-25"])
async def test_legacy_cancelled_frame_posts_and_leaves_the_stream_open(stamped_version: str | None) -> None:
"""Below 2026 — or before any stamped POST has revealed the version — the frame is
the spec's cancellation signal: it POSTs, and the request's stream stays open
(a 2025 disconnect is explicitly not a cancel)."""
parked = _ParkedSSEStream()
posted: list[dict[str, Any]] = []
frame_posted = anyio.Event()
handler = _sse_or_ack_handler(parked, posted, frame_posted)
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (_read, write),
):
metadata = (
ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: stamped_version})
if stamped_version is not None
else None
)
await write.send(
SessionMessage(
message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={}),
metadata=metadata,
)
)
await parked.opened.wait()
await write.send(
SessionMessage(
JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1})
)
)
await frame_posted.wait()
# Checked before teardown: exiting the transport cancels the parked POST.
assert not parked.closed.is_set()
assert [body["method"] for body in posted] == ["tools/call", "notifications/cancelled"]
@pytest.mark.anyio
@pytest.mark.parametrize(
"params",
[
pytest.param({"requestId": 999}, id="unknown-id"),
pytest.param({"requestId": True}, id="bool-must-not-alias-request-id-1"),
pytest.param({"requestId": "1"}, id="string-1-must-not-match-int-1"),
pytest.param({}, id="no-request-id"),
pytest.param(None, id="no-params"),
],
)
async def test_modern_cancelled_frames_matching_no_post_are_swallowed(params: dict[str, Any] | None) -> None:
"""At 2026 the frame is swallowed even when it aborts nothing — the wire defines no
client-to-server notifications, so a late cancel racing the response must not leak
a POST — and a mismatched id must not abort someone else's stream."""
parked = _ParkedSSEStream()
posted: list[dict[str, Any]] = []
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
posted.append(body)
if body.get("id") == 1:
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked)
return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(
SessionMessage(
message=JSONRPCRequest(jsonrpc="2.0", id=1, method="subscriptions/listen", params={}),
metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}),
)
)
await parked.opened.wait()
await write.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params=params))
)
# A follow-up request completing proves the loop moved past the swallowed frame.
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=2, method="ping", params={})))
reply = await read.receive()
# Checked before teardown: exiting the transport cancels the parked POST.
assert not parked.closed.is_set()
assert isinstance(reply, SessionMessage)
assert isinstance(reply.message, JSONRPCResponse)
assert reply.message.id == 2
assert [body["method"] for body in posted] == ["subscriptions/listen", "ping"]
@pytest.mark.anyio
async def test_handler_scoped_cancelled_frames_are_translated_at_modern_too() -> None:
"""A cancel carrying `ServerMessageMetadata` (a handler abandoning its own
back-channel request) still names one of OUR outbound ids — every spec-legal
cancel names a request its sender issued — so at 2026 it aborts that POST and
stays off the wire like any other."""
parked = _ParkedSSEStream()
posted: list[dict[str, Any]] = []
frame_posted = anyio.Event()
handler = _sse_or_ack_handler(parked, posted, frame_posted)
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (_read, write),
):
await write.send(
SessionMessage(
message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={}),
metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}),
)
)
await parked.opened.wait()
await write.send(
SessionMessage(
message=JSONRPCNotification(
jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1}
),
metadata=ServerMessageMetadata(related_request_id=99),
)
)
await parked.closed.wait()
assert [body["method"] for body in posted] == ["tools/call"]
assert not frame_posted.is_set()
@pytest.mark.anyio
async def test_cancel_for_a_request_sent_under_2025_still_posts_after_modern_adoption() -> None:
"""The translation follows the era the NAMED request was sent under, not the
cache at cancel time: a request POSTed under 2025 keeps 2025 cancellation
semantics (frame on the wire, stream left open) even after a later message
flips the negotiated version to 2026."""
parked = _ParkedSSEStream()
posted: list[dict[str, Any]] = []
frame_posted = anyio.Event()
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
posted.append(body)
if body.get("id") == 1:
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked)
if "id" in body:
return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
frame_posted.set()
return httpx.Response(202)
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(
SessionMessage(
message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={}),
metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: "2025-11-25"}),
)
)
await parked.opened.wait()
# A modern-stamped request flips the cached negotiated version.
await write.send(
SessionMessage(
message=JSONRPCRequest(jsonrpc="2.0", id=2, method="ping", params={}),
metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}),
)
)
reply = await read.receive()
assert isinstance(reply, SessionMessage)
assert isinstance(reply.message, JSONRPCResponse)
await write.send(
SessionMessage(
JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1})
)
)
await frame_posted.wait()
# Checked before teardown: exiting the transport cancels the parked POST.
assert not parked.closed.is_set()
assert [body["method"] for body in posted] == ["tools/call", "ping", "notifications/cancelled"]
class _SignalingBus(InMemorySubscriptionBus):
"""Signals subscribe/unsubscribe so a test observes the stream lifecycle through
the bus Protocol (the public seam) instead of polling handler internals."""
def __init__(self) -> None:
super().__init__()
self.subscribed = anyio.Event()
self.unsubscribed = anyio.Event()
def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]:
unsubscribe = super().subscribe(listener)
self.subscribed.set()
def unsubscribe_and_signal() -> None:
unsubscribe()
self.unsubscribed.set()
return unsubscribe_and_signal
@pytest.mark.anyio
async def test_scope_cancel_aborts_a_modern_listen_post_end_to_end() -> None:
"""Over a real ASGI bridge: cancelling the caller of a parked `subscriptions/listen`
closes the POST's response stream — the server treats the disconnect as the cancel
and releases the subscription — and no `notifications/cancelled` crosses the wire."""
bus = _SignalingBus()
server = Server("test", on_subscriptions_listen=ListenHandler(bus))
async def app(scope: Scope, receive: Receive, send: Send) -> None:
async with server.lifespan(server) as lifespan_state:
await handle_modern_request(server, None, False, lifespan_state, scope, receive, send)
posted_methods: list[str] = []
async def record_request(request: httpx.Request) -> None:
posted_methods.append(json.loads(request.content)["method"])
acked = anyio.Event()
async def on_notify(dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None) -> None:
assert method == "notifications/subscriptions/acknowledged"
acked.set()
on_request, _ = echo_handlers(Recorder())
with anyio.fail_after(15):
async with (
httpx.AsyncClient(
transport=StreamingASGITransport(app),
base_url="http://testserver",
event_hooks={"request": [record_request]},
) as http,
streamable_http_client("http://testserver/mcp", http_client=http) as (read, write),
):
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(read, write)
async with anyio.create_task_group() as tg: # pragma: no branch
await tg.start(dispatcher.run, on_request, on_notify)
listen_scope = anyio.CancelScope()
async def send_listen() -> None:
params: dict[str, Any] = {
"_meta": {
PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION,
CLIENT_INFO_META_KEY: {"name": "test-client", "version": "0"},
CLIENT_CAPABILITIES_META_KEY: {},
},
"notifications": {"toolsListChanged": True},
}
opts: CallOptions = {
"request_id": "listen-1",
"headers": {
MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION,
MCP_METHOD_HEADER: "subscriptions/listen",
},
}
with listen_scope:
await dispatcher.send_raw_request("subscriptions/listen", params, opts)
tg.start_soon(send_listen)
await acked.wait()
assert bus.subscribed.is_set()
assert not bus.unsubscribed.is_set()
listen_scope.cancel()
await bus.unsubscribed.wait()
tg.cancel_scope.cancel()
assert posted_methods == ["subscriptions/listen"]
class _CompletingSSEStream(httpx.AsyncByteStream):
"""An SSE body that delivers one JSON-RPC response, then parks in `aclose`.
Holding `aclose` keeps the finished POST task alive past its response, so a
test can re-register the same request id underneath it before releasing.
"""
def __init__(self, response_body: dict[str, Any]) -> None:
self._event = f"data: {json.dumps(response_body)}\n\n".encode()
self.release = anyio.Event()
async def __aiter__(self) -> AsyncIterator[bytes]:
yield self._event
async def aclose(self) -> None:
await self.release.wait()
@pytest.mark.anyio
async def test_a_finished_post_task_does_not_evict_a_reused_ids_new_registration() -> None:
"""Request ids are reusable once resolved; a finished POST task unwinding late
must not pop the successor's registration, or a cancel for the reused id would
find nothing to abort and the live POST would leak past the cancellation."""
completing = _CompletingSSEStream({"jsonrpc": "2.0", "id": "dup-1", "result": {}})
parked = _ParkedSSEStream()
posted: list[dict[str, Any]] = []
streams = [completing, parked]
def handler(request: httpx.Request) -> httpx.Response:
posted.append(json.loads(request.content))
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=streams.pop(0))
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
modern = ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION})
await write.send(
SessionMessage(
message=JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="tools/call", params={}),
metadata=modern,
)
)
reply = await read.receive()
assert isinstance(reply, SessionMessage)
assert isinstance(reply.message, JSONRPCResponse)
# The first task is now parked in `aclose`; reuse its id underneath it.
await write.send(
SessionMessage(
message=JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="subscriptions/listen", params={}),
metadata=modern,
)
)
await parked.opened.wait()
completing.release.set()
await anyio.wait_all_tasks_blocked()
# The successor's registration survived: a cancel still aborts it.
await write.send(
SessionMessage(
JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params={"requestId": "dup-1"})
)
)
await parked.closed.wait()
assert [body["method"] for body in posted] == ["tools/call", "subscriptions/listen"]
class _DyingSSEStream(httpx.AsyncByteStream):
"""Emits one id-less comment then breaks - a non-resumable stream dropping."""
def __init__(self) -> None:
self.opened = anyio.Event()
async def __aiter__(self) -> AsyncIterator[bytes]:
self.opened.set()
yield b": hello\n\n"
raise httpx.ReadError("connection reset")
async def aclose(self) -> None:
pass
@pytest.mark.anyio
async def test_a_non_resumable_sse_drop_resolves_the_request_with_an_error() -> None:
"""A per-request SSE stream that dies having carried no event ids can never deliver its
response; the transport resolves the waiter with CONNECTION_CLOSED instead of hanging forever."""
dying = _DyingSSEStream()
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=dying)
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(
SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={}))
)
reply = await read.receive()
assert isinstance(reply, SessionMessage)
assert isinstance(reply.message, JSONRPCError)
assert reply.message.id == "listen-1"
assert reply.message.error.code == CONNECTION_CLOSED
class _DeliverOnCommandSSEStream(httpx.AsyncByteStream):
"""Parks after opening, then delivers one JSON-RPC response when told."""
def __init__(self, response_body: dict[str, Any]) -> None:
self._event = f"data: {json.dumps(response_body)}\n\n".encode()
self.opened = anyio.Event()
self.deliver = anyio.Event()
async def __aiter__(self) -> AsyncIterator[bytes]:
self.opened.set()
await self.deliver.wait()
yield self._event
async def aclose(self) -> None:
pass
@pytest.mark.anyio
async def test_a_superseded_posts_late_real_response_cannot_answer_the_successor() -> None:
"""SDK-defined: re-issuing an id severs the superseded POST, so nothing from its
stream (a late real response, or a synthesized error for its death) can resolve
the reused id's waiter; only the successor's own response arrives."""
stale = _DeliverOnCommandSSEStream({"jsonrpc": "2.0", "id": "dup-1", "result": {"origin": "stale"}})
succeeding = _DeliverOnCommandSSEStream({"jsonrpc": "2.0", "id": "dup-1", "result": {"origin": "fresh"}})
streams: list[httpx.AsyncByteStream] = [stale, succeeding]
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=streams.pop(0))
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="tools/call", params={})))
await stale.opened.wait()
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="tools/call", params={})))
await succeeding.opened.wait()
stale.deliver.set()
await anyio.wait_all_tasks_blocked()
succeeding.deliver.set()
reply = await read.receive()
assert isinstance(reply, SessionMessage)
assert isinstance(reply.message, JSONRPCResponse), reply.message
assert reply.message.result == {"origin": "fresh"}
@pytest.mark.anyio
async def test_a_202_to_a_request_resolves_the_waiter_with_an_error() -> None:
"""SDK-defined: a server that answers a request with 202 Accepted has declared no
response will follow (the spec requires SSE or JSON for requests); the transport
resolves the waiter with INVALID_REQUEST instead of parking the caller forever."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(202)
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(
SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={}))
)
reply = await read.receive()
assert isinstance(reply, SessionMessage)
assert isinstance(reply.message, JSONRPCError)
assert reply.message.id == "listen-1"
assert reply.message.error.code == INVALID_REQUEST
def _abandoned_request_context(
http: httpx.AsyncClient, send: ContextSendStream[SessionMessage | Exception]
) -> RequestContext:
return RequestContext(
client=http,
session_id=None,
session_message=SessionMessage(
JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={})
),
metadata=None,
read_stream_writer=send,
)
@pytest.mark.anyio
async def test_exhausted_reconnection_attempts_resolve_the_request_with_an_error() -> None:
"""An id-bearing stream that exhausts its reconnection budget also resolves the waiter with CONNECTION_CLOSED."""
transport = StreamableHTTPTransport("http://test/mcp")
send, receive = create_context_streams[SessionMessage | Exception](1)
async with httpx.AsyncClient() as http:
with anyio.fail_after(5):
await transport._handle_reconnection( # pyright: ignore[reportPrivateUsage]
_abandoned_request_context(http, send), "evt-7", None, MAX_RECONNECTION_ATTEMPTS
)
reply = await receive.receive()
assert isinstance(reply, SessionMessage)
assert isinstance(reply.message, JSONRPCError)
assert reply.message.id == "listen-1"
assert reply.message.error.code == CONNECTION_CLOSED
send.close()
receive.close()
@pytest.mark.anyio
async def test_resolving_an_abandoned_request_after_the_reader_closed_is_contained() -> None:
"""Teardown race: a stream dying after the reader closed resolves best-effort and must not crash."""
transport = StreamableHTTPTransport("http://test/mcp")
send, receive = create_context_streams[SessionMessage | Exception](1)
receive.close()
async with httpx.AsyncClient() as http:
with anyio.fail_after(5):
await transport._handle_reconnection( # pyright: ignore[reportPrivateUsage]
_abandoned_request_context(http, send), "evt-7", None, MAX_RECONNECTION_ATTEMPTS
)
send.close()
+666
View File
@@ -0,0 +1,666 @@
"""Behavioral tests for the client-side `subscriptions/listen` driver (SDK-defined contract).
Public API only, against in-process servers; wire-shape assertions live in the interaction suite.
"""
from itertools import count
from typing import Any
import anyio
import mcp_types as types
import pytest
from mcp_types import SubscriptionFilter
import mcp.client.subscriptions as subscriptions_module
from mcp import Client, MCPError
from mcp.client.session import ClientSession
from mcp.client.subscriptions import (
ListenNotSupportedError,
ListenRoute,
PromptsListChanged,
ResourcesListChanged,
ResourceUpdated,
ServerEvent,
Subscription,
SubscriptionLost,
ToolsListChanged,
listen,
)
from mcp.server import Server, ServerRequestContext
from mcp.server.subscriptions import (
SUBSCRIPTION_ID_META_KEY,
InMemorySubscriptionBus,
ListenHandler,
)
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
from mcp.shared.dispatcher import CallOptions
pytestmark = pytest.mark.anyio
def _bus_server(bus: InMemorySubscriptionBus, *, max_subscriptions: int | None = None) -> Server[Any]:
"""A lowlevel server whose only feature is serving listen streams from `bus`."""
handler = (
ListenHandler(bus) if max_subscriptions is None else ListenHandler(bus, max_subscriptions=max_subscriptions)
)
return Server("subs", on_subscriptions_listen=handler)
async def _ack(ctx: ServerRequestContext[Any, Any], honored: SubscriptionFilter) -> dict[str, Any]:
"""Send a hand-rolled ack for a scripted listen handler; returns the stamped meta."""
assert ctx.request_id is not None
meta: dict[str, Any] = {SUBSCRIPTION_ID_META_KEY: ctx.request_id}
await ctx.session.send_notification(
types.SubscriptionsAcknowledgedNotification(
params=types.SubscriptionsAcknowledgedNotificationParams(notifications=honored, _meta=meta)
),
related_request_id=ctx.request_id,
)
return meta
async def test_listen_surfaces_the_honored_filter_and_subscription_id():
"""Entering waits for the server ack and surfaces the honored filter and subscription id."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus)) as client:
with anyio.fail_after(5):
async with client.listen( # pragma: no branch
tools_list_changed=True, resource_subscriptions=["note://todo"]
) as sub:
assert isinstance(sub, Subscription)
assert sub.honored.tools_list_changed is True
assert sub.honored.resource_subscriptions == ["note://todo"]
assert isinstance(sub.subscription_id, str)
assert sub.subscription_id.startswith("listen-")
async def test_listen_delivers_all_four_typed_event_kinds():
"""Bus publishes come back as the same typed event values, in order."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus)) as client:
with anyio.fail_after(5):
async with client.listen( # pragma: no branch
tools_list_changed=True,
prompts_list_changed=True,
resources_list_changed=True,
resource_subscriptions=["note://todo"],
) as sub:
for event in (
ToolsListChanged(),
PromptsListChanged(),
ResourcesListChanged(),
ResourceUpdated(uri="note://todo"),
):
await bus.publish(event)
assert await anext(sub) == event
async def test_unconsumed_duplicate_events_coalesce():
"""Events are level triggers: duplicates pending consumption collapse to one."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus)) as client:
with anyio.fail_after(5):
async with client.listen( # pragma: no branch
tools_list_changed=True, resource_subscriptions=["note://todo"]
) as sub:
for _ in range(3):
await bus.publish(ToolsListChanged())
await bus.publish(ResourceUpdated(uri="note://todo"))
await anyio.wait_all_tasks_blocked()
assert await anext(sub) == ToolsListChanged()
assert await anext(sub) == ResourceUpdated(uri="note://todo")
async def test_graceful_server_close_ends_the_loop_cleanly():
"""The server's deliberate close ends iteration cleanly, after draining prior events."""
bus = InMemorySubscriptionBus()
handler = ListenHandler(bus)
server = Server("subs", on_subscriptions_listen=handler)
events: list[object] = []
async with Client(server) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
await bus.publish(ToolsListChanged())
handler.close()
events.extend([event async for event in sub])
assert events == [ToolsListChanged()]
async def test_abrupt_stream_end_raises_subscription_lost():
"""A stream dying without the graceful result raises `SubscriptionLost` with the cause chained."""
proceed = anyio.Event()
async def dropping_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
await _ack(ctx, params.notifications)
await proceed.wait()
raise MCPError(types.INTERNAL_ERROR, "stream torn down")
server = Server("subs", on_subscriptions_listen=dropping_listen)
async with Client(server) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
proceed.set()
with pytest.raises(SubscriptionLost) as exc_info: # pragma: no branch
await anext(sub)
assert isinstance(exc_info.value.__cause__, MCPError)
assert exc_info.value.__cause__.error.message == "stream torn down"
async def test_listen_on_a_legacy_connection_raises_the_typed_steer():
"""On a 2025 connection `listen` fails fast with the typed error steering to the legacy verbs."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus), mode="legacy") as client:
with anyio.fail_after(5):
# Entering is where the guard fires; __aenter__ directly avoids an unreachable with-body.
with pytest.raises(ListenNotSupportedError) as exc_info: # pragma: no branch
await client.listen(tools_list_changed=True).__aenter__()
assert exc_info.value.negotiated_version == "2025-11-25"
assert "subscribe_resource" in str(exc_info.value)
async def test_server_rejection_raises_from_enter_not_from_iteration():
"""A server without the listen handler fails the open from entering the context."""
server = Server("no-listen")
async with Client(server) as client:
with anyio.fail_after(5):
with pytest.raises(MCPError) as exc_info: # pragma: no branch
await client.listen(tools_list_changed=True).__aenter__()
assert exc_info.value.error.code == types.METHOD_NOT_FOUND
async def test_immediate_result_without_ack_opens_already_closed():
"""A bare result with no ack yields a subscription already gracefully over: no filter, no events."""
async def degenerate_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
return types.SubscriptionsListenResult(_meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id})
server = Server("subs", on_subscriptions_listen=degenerate_listen)
async with Client(server) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert sub.honored == SubscriptionFilter()
with pytest.raises(StopAsyncIteration): # pragma: no branch
await anext(sub)
async def test_server_sent_cancelled_for_the_listen_id_raises_subscription_lost():
"""Server-sent notifications/cancelled for the listen id surfaces as a lost subscription."""
proceed = anyio.Event()
async def cancelling_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
await _ack(ctx, params.notifications)
await proceed.wait()
await ctx.session.send_notification(
types.CancelledNotification(params=types.CancelledNotificationParams(request_id=ctx.request_id)),
related_request_id=ctx.request_id,
)
await anyio.sleep_forever()
raise AssertionError("unreachable") # pragma: no cover
server = Server("subs", on_subscriptions_listen=cancelling_listen)
async with Client(server) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
proceed.set()
with pytest.raises(SubscriptionLost): # pragma: no branch
await anext(sub)
async def test_exiting_the_context_frees_the_server_slot():
"""Leaving the block ends the subscription server-side: a one-slot handler admits a second listen."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus, max_subscriptions=1)) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as first:
assert first.honored.tools_list_changed is True
async with client.listen(tools_list_changed=True) as second: # pragma: no branch
assert second.honored.tools_list_changed is True
assert second.subscription_id != first.subscription_id
async def test_concurrent_subscriptions_demux_independently():
"""Two open subscriptions each receive only their own filter's events."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus)) as client:
with anyio.fail_after(5):
async with ( # pragma: no branch
client.listen(tools_list_changed=True) as tools_sub,
client.listen(resource_subscriptions=["note://todo"]) as notes_sub,
):
await bus.publish(ToolsListChanged())
await bus.publish(ResourceUpdated(uri="note://todo"))
assert await anext(tools_sub) == ToolsListChanged()
assert await anext(notes_sub) == ResourceUpdated(uri="note://todo")
# Neither stream received the other's event.
await bus.publish(ToolsListChanged())
assert await anext(tools_sub) == ToolsListChanged()
async def test_change_notifications_still_reach_message_handler():
"""The demux tees: a delivered event's notification still reaches message_handler; the ack never does."""
bus = InMemorySubscriptionBus()
seen: list[str] = []
async def on_message(message: object) -> None:
assert not isinstance(message, types.SubscriptionsAcknowledgedNotification)
if isinstance(message, types.ToolListChangedNotification): # pragma: no branch
seen.append("tools-changed")
async with Client(_bus_server(bus), message_handler=on_message) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
await bus.publish(ToolsListChanged())
assert await anext(sub) == ToolsListChanged()
await anyio.wait_all_tasks_blocked()
assert seen == ["tools-changed"]
async def test_enter_times_out_when_the_ack_never_arrives():
"""The ack wait rides the session's read timeout, so a wedged server cannot hang the open."""
async def silent_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
await anyio.sleep_forever()
raise AssertionError("unreachable") # pragma: no cover
server = Server("subs", on_subscriptions_listen=silent_listen)
async with Client(server, read_timeout_seconds=0.05) as client:
with anyio.fail_after(5):
with pytest.raises(TimeoutError): # pragma: no branch
await client.listen(tools_list_changed=True).__aenter__()
async def test_an_open_stream_outlives_the_session_read_timeout():
"""The listen request is exempt from the read timeout: the stream delivers after the deadline."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus), read_timeout_seconds=0.05) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
# Real clock on purpose: this pins a timeout feature.
await anyio.sleep(0.2)
await bus.publish(ToolsListChanged())
assert await anext(sub) == ToolsListChanged()
async def test_a_duplicate_ack_does_not_overwrite_the_honored_filter():
"""The first ack wins; a later conflicting ack is a no-op."""
proceed = anyio.Event()
async def double_acking_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
await _ack(ctx, params.notifications)
await _ack(ctx, SubscriptionFilter())
await proceed.wait()
return types.SubscriptionsListenResult(_meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id})
server = Server("subs", on_subscriptions_listen=double_acking_listen)
async with Client(server) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert sub.honored.tools_list_changed is True
proceed.set()
async def test_a_non_event_frame_with_the_subscription_id_is_teed_not_delivered():
"""A stamped non-event notification never surfaces as an event; it flows to message_handler."""
proceed = anyio.Event()
async def logging_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
meta = await _ack(ctx, params.notifications)
await ctx.session.send_notification(
types.LoggingMessageNotification(
params=types.LoggingMessageNotificationParams(level="info", data="not an event", _meta=meta)
),
related_request_id=ctx.request_id,
)
await proceed.wait()
return types.SubscriptionsListenResult(_meta=meta)
logged: list[str] = []
async def on_message(message: object) -> None:
if isinstance(message, types.LoggingMessageNotification): # pragma: no branch
logged.append(str(message.params.data))
server = Server("subs", on_subscriptions_listen=logging_listen)
async with Client(server, message_handler=on_message) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
await anyio.wait_all_tasks_blocked()
proceed.set()
with pytest.raises(StopAsyncIteration): # pragma: no branch
await anext(sub)
assert logged == ["not an event"]
async def test_session_teardown_unblocks_a_sibling_consumer_with_subscription_lost():
"""Session teardown settles every open route as lost, unblocking parked consumers."""
bus = InMemorySubscriptionBus()
outcome: list[str] = []
entered = anyio.Event()
async def consume(client: Client) -> None:
with pytest.raises(SubscriptionLost):
async with client.listen(tools_list_changed=True) as sub:
entered.set()
await anext(sub)
outcome.append("lost")
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
async with Client(_bus_server(bus)) as client: # pragma: no branch
tg.start_soon(consume, client)
await entered.wait()
assert outcome == ["lost"]
async def test_server_cancel_before_the_ack_raises_subscription_lost_from_enter():
"""A stream torn down before it was ever acknowledged is a failed open: enter raises."""
async def cancel_first_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
await ctx.session.send_notification(
types.CancelledNotification(params=types.CancelledNotificationParams(request_id=ctx.request_id)),
related_request_id=ctx.request_id,
)
await anyio.sleep_forever()
raise AssertionError("unreachable") # pragma: no cover
server = Server("subs", on_subscriptions_listen=cancel_first_listen)
async with Client(server) as client:
with anyio.fail_after(5):
with pytest.raises(SubscriptionLost, match="before it was acknowledged"): # pragma: no branch
await client.listen(tools_list_changed=True).__aenter__()
async def test_listen_on_an_exited_session_raises_and_leaks_no_route():
"""Opening on an exited session fails loudly and leaves no demux registration behind."""
bus = InMemorySubscriptionBus()
client = Client(_bus_server(bus))
async with client:
session = client.session
with pytest.raises(RuntimeError):
await listen(session, tools_list_changed=True).__aenter__()
assert session._listen_routes == {} # pyright: ignore[reportPrivateUsage]
async def test_listen_on_a_never_entered_session_raises_runtime_error():
"""An adopted-but-never-entered session has no task group to drive the stream."""
dispatcher, _peer = create_direct_dispatcher_pair()
session = ClientSession(dispatcher=dispatcher)
session.adopt(
types.DiscoverResult(
supported_versions=["2026-07-28"],
capabilities=types.ServerCapabilities(),
server_info=types.Implementation(name="stub", version="0"),
)
)
with pytest.raises(RuntimeError, match="entered session"):
await listen(session, tools_list_changed=True).__aenter__()
assert session._listen_routes == {} # pyright: ignore[reportPrivateUsage]
async def test_a_retained_handle_after_exit_does_not_serve_stale_events():
"""Leaving the block abandons the backlog: a stashed handle must not replay buffered events."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus)) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub:
await bus.publish(ToolsListChanged())
await anyio.wait_all_tasks_blocked()
with pytest.raises(StopAsyncIteration): # pragma: no branch
await anext(sub)
async def test_a_stray_ack_outside_the_driver_namespace_still_reaches_message_handler():
"""Acks for ids the driver never minted flow to message_handler (the raw-listen escape hatch)."""
proceed = anyio.Event()
async def stray_acking_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
assert ctx.request_id is not None
await _ack(ctx, params.notifications)
await ctx.session.send_notification(
types.SubscriptionsAcknowledgedNotification(
params=types.SubscriptionsAcknowledgedNotificationParams(
notifications=SubscriptionFilter(), _meta={SUBSCRIPTION_ID_META_KEY: 424242}
)
),
related_request_id=ctx.request_id,
)
await proceed.wait()
return types.SubscriptionsListenResult(_meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id})
handled: list[str] = []
async def on_message(message: object) -> None:
handled.append(type(message).__name__)
server = Server("subs", on_subscriptions_listen=stray_acking_listen)
async with Client(server, message_handler=on_message) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
await anyio.wait_all_tasks_blocked()
proceed.set()
with pytest.raises(StopAsyncIteration): # pragma: no branch
await anext(sub)
assert "SubscriptionsAcknowledgedNotification" in handled
async def test_a_bare_string_for_resource_subscriptions_is_rejected():
"""A bare string would explode into per-character URIs; it is rejected before touching the wire."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus)) as client:
with pytest.raises(TypeError, match="sequence of URIs"):
await client.listen(resource_subscriptions="note://todo").__aenter__() # pyright: ignore[reportArgumentType]
def test_the_route_admits_only_honored_events_and_only_while_live():
"""Route admission: nothing before the ack, only honored events while live, nothing after the end."""
route = ListenRoute()
route.deliver(ToolsListChanged())
assert route._pending == {} # pyright: ignore[reportPrivateUsage]
route.set_acked(SubscriptionFilter(tools_list_changed=True, resource_subscriptions=["note://todo"]))
route.deliver(PromptsListChanged()) # kind not honored
route.deliver(ResourceUpdated(uri="note://todo/draft")) # sub-resource of a subscribed URI: spec says admit
route.deliver(ResourceUpdated(uri="note://todo"))
route.deliver(ToolsListChanged())
route.deliver(ToolsListChanged()) # duplicate pending consumption collapses
assert list(route._pending) == [ # pyright: ignore[reportPrivateUsage]
ResourceUpdated(uri="note://todo/draft"),
ResourceUpdated(uri="note://todo"),
ToolsListChanged(),
]
route.settle("graceful")
route.deliver(ResourceUpdated(uri="note://todo")) # post-close noise is refused
assert len(route._pending) == 3 # pyright: ignore[reportPrivateUsage]
def test_a_peer_flooding_distinct_uris_costs_the_subscription_not_client_memory():
"""A peer flooding distinct URIs trips the `_MAX_PENDING_EVENTS` backstop: the route
settles lost instead of growing client memory without bound."""
route = ListenRoute()
route.set_acked(SubscriptionFilter(resource_subscriptions=["note://todo"]))
for n in range(subscriptions_module._MAX_PENDING_EVENTS): # pyright: ignore[reportPrivateUsage]
route.deliver(ResourceUpdated(uri=f"note://todo/{n}"))
assert route.end is None
route.deliver(ResourceUpdated(uri="note://todo/one-too-many"))
assert route.end == "lost"
assert route.error is not None
assert "backlog" in route.error.error.message
# The overflowing event was not queued.
assert len(route._pending) == subscriptions_module._MAX_PENDING_EVENTS # pyright: ignore[reportPrivateUsage]
async def test_a_cancelled_on_event_barrier_does_not_lose_the_event():
"""Cancelling `anext` mid-barrier leaves the event queued; the next `anext` re-runs the
idempotent barrier and returns it."""
bus = InMemorySubscriptionBus()
entered = anyio.Event()
release = anyio.Event()
async def parked_barrier(event: ServerEvent) -> None:
entered.set()
await release.wait()
async with Client(_bus_server(bus)) as client:
with anyio.fail_after(5):
async with listen(
client.session, tools_list_changed=True, on_event=parked_barrier
) as sub: # pragma: no branch
await bus.publish(ToolsListChanged())
async with anyio.create_task_group() as tg:
cancel_scope = anyio.CancelScope()
async def first_attempt() -> None:
with cancel_scope:
await anext(sub)
raise AssertionError("must be cancelled mid-barrier") # pragma: no cover
tg.start_soon(first_attempt)
await entered.wait()
cancel_scope.cancel()
release.set()
assert await anext(sub) == ToolsListChanged()
async def test_events_outside_the_honored_filter_are_never_delivered():
"""A server violating its acknowledged filter cannot reach the consumer or grow the backlog."""
proceed = anyio.Event()
async def overreaching_listen(
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
meta = await _ack(ctx, params.notifications) # honors exactly what was requested: tools only
await ctx.session.send_notification(
types.ResourceUpdatedNotification(
params=types.ResourceUpdatedNotificationParams(uri="note://uninvited", _meta=meta)
),
related_request_id=ctx.request_id,
)
await ctx.session.send_notification(
types.ToolListChangedNotification(params=types.NotificationParams(_meta=meta)),
related_request_id=ctx.request_id,
)
await proceed.wait()
return types.SubscriptionsListenResult(_meta=meta)
server = Server("subs", on_subscriptions_listen=overreaching_listen)
async with Client(server) as client:
with anyio.fail_after(5):
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert await anext(sub) == ToolsListChanged()
proceed.set()
with pytest.raises(StopAsyncIteration): # pragma: no branch
await anext(sub)
async def test_the_on_event_barrier_completes_before_each_event_is_returned():
"""`on_event` is awaited before the iterator returns each event (the Client wires cache eviction here)."""
bus = InMemorySubscriptionBus()
order: list[str] = []
async def barrier(event: ServerEvent) -> None:
order.append(f"barrier:{type(event).__name__}")
async with Client(_bus_server(bus)) as client:
with anyio.fail_after(5):
async with listen(client.session, tools_list_changed=True, on_event=barrier) as sub: # pragma: no branch
await bus.publish(ToolsListChanged())
event = await anext(sub)
order.append(f"returned:{type(event).__name__}")
assert order == ["barrier:ToolsListChanged", "returned:ToolsListChanged"]
async def test_client_listen_installs_the_cache_eviction_barrier_exactly_when_a_cache_exists():
"""`Client.listen` wires the response-cache evictor as the barrier only when a cache exists."""
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus)) as cached_client:
with anyio.fail_after(5):
async with cached_client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert sub._on_event == cached_client._evict_for_listen_event # pyright: ignore[reportPrivateUsage]
async with Client(_bus_server(bus), cache=False) as uncached_client:
with anyio.fail_after(5):
async with uncached_client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert sub._on_event is None # pyright: ignore[reportPrivateUsage]
async def test_the_cache_eviction_barrier_maps_events_and_contains_store_faults(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The barrier evicts through the same notification mapping as the message_handler wrapper;
a raising store costs a log line, not the delivery."""
client = Client(_bus_server(InMemorySubscriptionBus()))
cache = client._response_cache # pyright: ignore[reportPrivateUsage]
assert cache is not None
evicted: list[types.ServerNotification] = []
async def record(notification: types.ServerNotification) -> None:
evicted.append(notification)
monkeypatch.setattr(cache, "evict_for_notification", record)
await client._evict_for_listen_event(ResourceUpdated(uri="note://x")) # pyright: ignore[reportPrivateUsage]
assert isinstance(evicted[0], types.ResourceUpdatedNotification)
assert evicted[0].params.uri == "note://x"
async def broken(notification: types.ServerNotification) -> None:
raise RuntimeError("store down")
monkeypatch.setattr(cache, "evict_for_notification", broken)
# Contained: a cache fault must not block delivery.
await client._evict_for_listen_event(ToolsListChanged()) # pyright: ignore[reportPrivateUsage]
async def test_a_raw_request_id_collision_fails_the_subscription_not_the_session(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A raw caller occupying the driver's next minted id fails that one listen from enter;
the session survives and the next listen opens normally."""
monkeypatch.setattr(subscriptions_module, "_listen_ids", count(7000))
bus = InMemorySubscriptionBus()
async with Client(_bus_server(bus)) as client:
with anyio.fail_after(5):
async with anyio.create_task_group() as tg: # pragma: no branch
raw_scope = anyio.CancelScope()
async def raw_listen() -> None:
request = types.SubscriptionsListenRequest(
params=types.SubscriptionsListenRequestParams(
notifications=SubscriptionFilter(tools_list_changed=True)
)
)
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
opts: CallOptions = {"request_id": "listen-7000"}
client.session._stamp(data, opts) # pyright: ignore[reportPrivateUsage]
with raw_scope:
await client.session._dispatcher.send_raw_request( # pyright: ignore[reportPrivateUsage]
data["method"], data.get("params"), opts
)
tg.start_soon(raw_listen)
await anyio.wait_all_tasks_blocked()
with pytest.raises(MCPError) as exc_info:
await client.listen(tools_list_changed=True).__aenter__()
assert "already in flight" in exc_info.value.error.message
# The failed open released the colliding id's demux registration.
assert client.session._listen_routes == {} # pyright: ignore[reportPrivateUsage]
raw_scope.cancel()
async with client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert sub.subscription_id == "listen-7001"
@@ -0,0 +1,105 @@
"""Regression tests for memory stream leaks in client transports.
When a connection error occurs (404, 403, ConnectError), transport context
managers must close ALL 4 memory stream ends they created. anyio memory streams
are paired but independent — closing the writer does NOT close the reader.
Unclosed stream ends emit ResourceWarning on GC, which pytest promotes to a
test failure in whatever test happens to be running when GC triggers.
These tests force GC after the transport context exits, so any leaked stream
triggers a ResourceWarning immediately and deterministically here, rather than
nondeterministically in an unrelated later test.
"""
import gc
import sys
from collections.abc import Iterator
from contextlib import contextmanager
import httpx
import pytest
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamable_http_client
@contextmanager
def _assert_no_memory_stream_leak() -> Iterator[None]:
"""Fail if any anyio MemoryObject stream emits ResourceWarning during the block.
Uses a custom sys.unraisablehook to capture ONLY MemoryObject stream leaks,
ignoring unrelated resources (e.g. PipeHandle from flaky stdio tests on the
same xdist worker). gc.collect() is forced after the block to make leaks
deterministic.
"""
leaked: list[str] = []
old_hook = sys.unraisablehook
def hook(args: "sys.UnraisableHookArgs") -> None: # pragma: no cover
# Only executes if a leak occurs (i.e. the bug is present).
# args.object is the __del__ function (not the stream instance) when
# unraisablehook fires from a finalizer, so check exc_value — the
# actual ResourceWarning("Unclosed <MemoryObjectSendStream at ...>").
# Non-MemoryObject unraisables (e.g. PipeHandle leaked by an earlier
# flaky test on the same xdist worker) are deliberately ignored —
# this test should not fail for another test's resource leak.
if "MemoryObject" in str(args.exc_value):
leaked.append(str(args.exc_value))
sys.unraisablehook = hook
try:
yield
gc.collect()
assert not leaked, f"Memory streams leaked: {leaked}"
finally:
sys.unraisablehook = old_hook
@pytest.mark.anyio
async def test_sse_client_closes_all_streams_on_connection_error(free_tcp_port: int) -> None:
"""sse_client creates streams only after the SSE connection succeeds, so a
ConnectError propagates directly with nothing to leak.
Before the fix, streams were created before connecting and only 2 of 4 were
closed in the finally block.
"""
with _assert_no_memory_stream_leak():
with pytest.raises(httpx.ConnectError):
async with sse_client(f"http://127.0.0.1:{free_tcp_port}/sse"):
pytest.fail("should not reach here") # pragma: no cover
@pytest.mark.anyio
async def test_sse_client_closes_all_streams_on_http_error() -> None:
"""sse_client creates streams only after raise_for_status() passes, so an
HTTPStatusError from a 4xx/5xx response propagates bare (not wrapped in an
ExceptionGroup) with nothing to leak — the task group is never entered.
"""
def return_403(request: httpx.Request) -> httpx.Response:
return httpx.Response(403)
def mock_factory(
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None = None,
auth: httpx.Auth | None = None,
) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=httpx.MockTransport(return_403))
with _assert_no_memory_stream_leak():
with pytest.raises(httpx.HTTPStatusError):
async with sse_client("http://test/sse", httpx_client_factory=mock_factory):
pytest.fail("should not reach here") # pragma: no cover
@pytest.mark.anyio
async def test_streamable_http_client_closes_all_streams_on_exit() -> None:
"""streamable_http_client must close all 4 stream ends on exit.
Before the fix, read_stream was never closed — not even on the happy path.
This test enters and exits the context without sending any messages, so no
network connection is ever attempted (streamable_http connects lazily).
"""
with _assert_no_memory_stream_leak():
async with streamable_http_client("http://127.0.0.1:1/mcp"):
pass
View File
+149
View File
@@ -0,0 +1,149 @@
"""Tests for InMemoryTransport."""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
import anyio
import anyio.lowlevel
import mcp_types as types
import pytest
from mcp_types import ListResourcesResult, Resource
from mcp import Client
from mcp.client import _memory
from mcp.client._memory import InMemoryTransport
from mcp.server import Server, ServerRequestContext
from mcp.server.mcpserver import MCPServer
@pytest.fixture
def simple_server() -> Server:
"""Create a simple MCP server for testing."""
async def handle_list_resources(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> ListResourcesResult: # pragma: no cover
return ListResourcesResult(
resources=[
Resource(
uri="memory://test",
name="Test Resource",
description="A test resource",
)
]
)
return Server(name="test_server", on_list_resources=handle_list_resources)
@pytest.fixture
def mcpserver_server() -> MCPServer:
"""Create an MCPServer server for testing."""
server = MCPServer("test")
@server.tool()
def greet(name: str) -> str:
"""Greet someone by name."""
return f"Hello, {name}!"
@server.resource("test://resource")
def test_resource() -> str: # pragma: no cover
"""A test resource."""
return "Test content"
return server
pytestmark = pytest.mark.anyio
async def test_with_server(simple_server: Server):
"""Test creating transport with a Server instance."""
transport = InMemoryTransport(simple_server)
async with transport as (read_stream, write_stream):
assert read_stream is not None
assert write_stream is not None
async def test_with_mcpserver(mcpserver_server: MCPServer):
"""Test creating transport with an MCPServer instance."""
transport = InMemoryTransport(mcpserver_server)
async with transport as (read_stream, write_stream):
assert read_stream is not None
assert write_stream is not None
async def test_server_is_running(mcpserver_server: MCPServer):
"""Test that the server is running and responding to requests."""
async with Client(mcpserver_server, mode="legacy") as client:
assert client.server_capabilities.tools is not None
async def test_list_tools(mcpserver_server: MCPServer):
"""Test listing tools through the transport."""
async with Client(mcpserver_server, mode="legacy") as client:
tools_result = await client.list_tools()
assert len(tools_result.tools) > 0
tool_names = [t.name for t in tools_result.tools]
assert "greet" in tool_names
async def test_call_tool(mcpserver_server: MCPServer):
"""Test calling a tool through the transport."""
async with Client(mcpserver_server, mode="legacy") as client:
result = await client.call_tool("greet", {"name": "World"})
assert result is not None
assert len(result.content) > 0
assert "Hello, World!" in str(result.content[0])
async def test_raise_exceptions(mcpserver_server: MCPServer):
"""Test that raise_exceptions parameter is passed through."""
transport = InMemoryTransport(mcpserver_server, raise_exceptions=True)
async with transport as (read_stream, _write_stream):
assert read_stream is not None
async def test_aexit_with_well_behaved_lifespan_runs_teardown_without_cancel():
"""A lifespan that finishes promptly on EOF should run to completion.
The transport closes the streams first and waits for the server to exit
naturally, so teardown observes no cancellation.
"""
teardown_ran = anyio.Event()
@asynccontextmanager
async def lifespan(_: Server[Any]) -> AsyncIterator[dict[str, Any]]:
yield {}
await anyio.lowlevel.checkpoint()
teardown_ran.set()
server = Server(name="test_server", lifespan=lifespan)
with anyio.fail_after(5):
async with InMemoryTransport(server):
pass
assert teardown_ran.is_set()
async def test_aexit_with_blocking_lifespan_is_bounded(monkeypatch: pytest.MonkeyPatch):
"""A lifespan that never returns must not hang `__aexit__` forever.
After EOFing the server the transport waits `SERVER_SHUTDOWN_GRACE` for a
natural exit, then cancels the server task as a backstop so the
task-group join completes.
"""
monkeypatch.setattr(_memory, "SERVER_SHUTDOWN_GRACE", 0.05)
teardown_started = anyio.Event()
@asynccontextmanager
async def blocking_lifespan(_: Server[Any]) -> AsyncIterator[dict[str, Any]]:
yield {}
teardown_started.set()
await anyio.Event().wait()
server = Server(name="test_server", lifespan=blocking_lifespan)
with anyio.fail_after(5):
async with InMemoryTransport(server):
pass
assert teardown_started.is_set()