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
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:
@@ -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")
|
||||
@@ -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
@@ -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
@@ -0,0 +1,573 @@
|
||||
"""`Client` + `ClientExtension` integration: extension declarations fold into the session at
|
||||
construction, and `call_tool` drives claim resolvers transparently against real `MCPServer`s.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
import anyio
|
||||
import mcp_types as types
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import CallToolResult, Result, TextContent
|
||||
from mcp_types.version import LATEST_MODERN_VERSION
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import assert_type
|
||||
|
||||
from mcp.client import ClaimContext, ClientExtension, NotificationBinding, ResultClaim, advertise
|
||||
from mcp.client.client import Client
|
||||
from mcp.client.session import ClientRequestContext, _CallToolResultAdapter
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.server.context import CallNext, HandlerResult
|
||||
from mcp.server.extension import Extension
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
_VOUCHER_EXT = "com.example/voucher"
|
||||
_RIVAL_EXT = "com.example/rival"
|
||||
|
||||
_NAME_SCHEMA = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}
|
||||
|
||||
|
||||
def _name_elicitation() -> types.ElicitRequest:
|
||||
return types.ElicitRequest(
|
||||
params=types.ElicitRequestFormParams(message="What is your name?", requested_schema=_NAME_SCHEMA)
|
||||
)
|
||||
|
||||
|
||||
class VoucherResult(Result):
|
||||
"""The claimed `tools/call` shape, tagged `voucher`, carrying a vendor top-level field."""
|
||||
|
||||
result_type: Literal["voucher"] = "voucher"
|
||||
voucher_code: str | None = None
|
||||
|
||||
|
||||
_Resolver = Callable[[VoucherResult, ClaimContext], Awaitable[CallToolResult]]
|
||||
|
||||
|
||||
class _VoucherExtension(ClientExtension):
|
||||
"""Client half: claims the `voucher` tag with the supplied resolver."""
|
||||
|
||||
identifier = _VOUCHER_EXT
|
||||
|
||||
def __init__(self, resolve: _Resolver) -> None:
|
||||
self._resolve = resolve
|
||||
|
||||
def claims(self) -> Sequence[ResultClaim[Any]]:
|
||||
return [ResultClaim(result_type="voucher", model=VoucherResult, resolve=self._resolve)]
|
||||
|
||||
|
||||
class _VoucherIssuer(Extension):
|
||||
"""Server half: rewrites every `tools/call` result into the vendor-claimed shape."""
|
||||
|
||||
identifier = _VOUCHER_EXT
|
||||
|
||||
async def intercept_tool_call(
|
||||
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
|
||||
) -> HandlerResult:
|
||||
return {"resultType": "voucher", "voucherCode": "v-42"}
|
||||
|
||||
|
||||
class _TwoRoundVoucherIssuer(Extension):
|
||||
"""Server half: demands input on the first round, then issues the claimed shape."""
|
||||
|
||||
identifier = _VOUCHER_EXT
|
||||
|
||||
async def intercept_tool_call(
|
||||
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
|
||||
) -> HandlerResult:
|
||||
if params.input_responses is None:
|
||||
return types.InputRequiredResult(input_requests={"user_name": _name_elicitation()})
|
||||
return {"resultType": "voucher", "voucherCode": "after-input"}
|
||||
|
||||
|
||||
def _voucher_server(issuer: Extension | None = None) -> MCPServer:
|
||||
"""An `MCPServer` whose `issue` tool the server extension rewrites into the claimed shape."""
|
||||
server = MCPServer("vouchers", extensions=[issuer if issuer is not None else _VoucherIssuer()])
|
||||
|
||||
@server.tool()
|
||||
def issue() -> CallToolResult:
|
||||
"""Issue a voucher."""
|
||||
raise NotImplementedError # the server extension short-circuits before the tool runs
|
||||
|
||||
return server
|
||||
|
||||
|
||||
def _structured_voucher_server() -> MCPServer:
|
||||
"""Like `_voucher_server`, but `issue` declares an output schema (`-> str`)."""
|
||||
server = MCPServer("vouchers", extensions=[_VoucherIssuer()])
|
||||
|
||||
@server.tool()
|
||||
def issue() -> str:
|
||||
"""Issue a voucher."""
|
||||
raise NotImplementedError # the server extension short-circuits before the tool runs
|
||||
|
||||
return server
|
||||
|
||||
|
||||
def _add_server() -> MCPServer:
|
||||
"""A plain claim-less server with one ordinary tool."""
|
||||
server = MCPServer("plain")
|
||||
|
||||
@server.tool()
|
||||
def add(a: int, b: int) -> int:
|
||||
"""Add two integers."""
|
||||
return a + b
|
||||
|
||||
return server
|
||||
|
||||
|
||||
# Construction-time validation
|
||||
|
||||
|
||||
class _CouponResult(Result):
|
||||
result_type: Literal["coupon"] = "coupon"
|
||||
|
||||
|
||||
async def _unreachable_coupon_resolve(claimed: _CouponResult, ctx: ClaimContext) -> CallToolResult:
|
||||
raise NotImplementedError # the wrong resolver for a voucher; must never run
|
||||
|
||||
|
||||
class _CouponExtension(ClientExtension):
|
||||
identifier = "com.example/coupons"
|
||||
|
||||
def claims(self) -> Sequence[ResultClaim[Any]]:
|
||||
return [ResultClaim(result_type="coupon", model=_CouponResult, resolve=_unreachable_coupon_resolve)]
|
||||
|
||||
|
||||
class _SelfConflictingClaims(ClientExtension):
|
||||
identifier = "com.example/twice"
|
||||
|
||||
def claims(self) -> Sequence[ResultClaim[Any]]:
|
||||
return [
|
||||
ResultClaim(result_type="twice", model=_TwiceResult, resolve=_unreachable_twice_resolve),
|
||||
ResultClaim(result_type="twice", model=_TwiceResult, resolve=_unreachable_twice_resolve),
|
||||
]
|
||||
|
||||
|
||||
class _TwiceResult(Result):
|
||||
result_type: Literal["twice"] = "twice"
|
||||
|
||||
|
||||
async def _unreachable_twice_resolve(claimed: _TwiceResult, ctx: ClaimContext) -> CallToolResult:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def test_mapping_extensions_get_the_migration_error() -> None:
|
||||
"""SDK-defined: the replaced dict form fails with a message naming the new shape."""
|
||||
with pytest.raises(TypeError) as exc_info:
|
||||
Client(_add_server(), extensions=cast("Sequence[ClientExtension]", {"com.example/ui": {}}))
|
||||
|
||||
assert str(exc_info.value) == snapshot(
|
||||
"extensions= takes a sequence of ClientExtension instances. The mapping form was "
|
||||
"replaced: use advertise(identifier, settings) for advertise-only entries"
|
||||
)
|
||||
|
||||
|
||||
def test_one_extension_claiming_a_tag_twice_reads_as_one_owner() -> None:
|
||||
"""SDK-defined: a self-conflict names the one extension once, not as a pair."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
Client(_add_server(), extensions=[_SelfConflictingClaims()])
|
||||
|
||||
assert str(exc_info.value) == snapshot(
|
||||
"extension 'com.example/twice' claims resultType 'twice'; a wire tag can have only one resolver"
|
||||
)
|
||||
|
||||
|
||||
def test_bare_extension_instance_is_rejected_with_the_fix_named() -> None:
|
||||
"""SDK-defined: an instance whose class never set `identifier` fails construction naming the type and the fix."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
Client(_add_server(), extensions=[ClientExtension()])
|
||||
|
||||
assert str(exc_info.value) == snapshot(
|
||||
"ClientExtension has no `identifier`; a ClientExtension must set the `identifier` "
|
||||
"class attribute (or assign one in `__init__`) before it can be used"
|
||||
)
|
||||
|
||||
|
||||
class _SelfAssignedBadId(ClientExtension):
|
||||
"""Assigns a malformed identifier in `__init__`, invisible at class definition."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.identifier = "not-prefixed"
|
||||
|
||||
|
||||
def test_invalid_per_instance_identifier_raises_the_validators_error() -> None:
|
||||
"""SDK-defined: per-instance identifiers are validated when the Client consumes the extension."""
|
||||
with pytest.raises(TypeError) as exc_info:
|
||||
Client(_add_server(), extensions=[_SelfAssignedBadId()])
|
||||
|
||||
assert str(exc_info.value) == snapshot(
|
||||
"_SelfAssignedBadId.identifier must be a `vendor-prefix/name` string "
|
||||
"(reverse-DNS prefix required), got 'not-prefixed'"
|
||||
)
|
||||
|
||||
|
||||
def test_duplicate_extension_identifiers_are_rejected_naming_the_identifier() -> None:
|
||||
"""SDK-defined: one identifier cannot appear twice across the extensions sequence."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
Client(_add_server(), extensions=[advertise(_VOUCHER_EXT), advertise(_VOUCHER_EXT, {"a": 1})])
|
||||
|
||||
assert str(exc_info.value) == snapshot("extension identifier 'com.example/voucher' is passed more than once")
|
||||
|
||||
|
||||
async def _unreachable_resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class _RivalVoucherExtension(ClientExtension):
|
||||
identifier = _RIVAL_EXT
|
||||
|
||||
def claims(self) -> Sequence[ResultClaim[Any]]:
|
||||
return [ResultClaim(result_type="voucher", model=VoucherResult, resolve=_unreachable_resolve)]
|
||||
|
||||
|
||||
def test_conflicting_claims_across_extensions_name_both_owners() -> None:
|
||||
"""SDK-defined: two extensions claiming the same tag fail at construction with both owners named."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
Client(_add_server(), extensions=[_VoucherExtension(_unreachable_resolve), _RivalVoucherExtension()])
|
||||
|
||||
assert str(exc_info.value) == snapshot(
|
||||
"extensions 'com.example/voucher' and 'com.example/rival' both claim resultType "
|
||||
"'voucher'; a wire tag can have only one resolver"
|
||||
)
|
||||
|
||||
|
||||
class _EventParams(BaseModel):
|
||||
seq: int
|
||||
|
||||
|
||||
async def _unreachable_handler(params: _EventParams) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class _ObserverA(ClientExtension):
|
||||
identifier = "com.example/observer-a"
|
||||
|
||||
def notifications(self) -> Sequence[NotificationBinding[Any]]:
|
||||
return [
|
||||
NotificationBinding(
|
||||
method="notifications/vendor/event", params_type=_EventParams, handler=_unreachable_handler
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class _ObserverB(ClientExtension):
|
||||
identifier = "com.example/observer-b"
|
||||
|
||||
def notifications(self) -> Sequence[NotificationBinding[Any]]:
|
||||
return [
|
||||
NotificationBinding(
|
||||
method="notifications/vendor/event", params_type=_EventParams, handler=_unreachable_handler
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_conflicting_notification_bindings_name_both_owners() -> None:
|
||||
"""SDK-defined: two extensions binding the same notification method fail with both owners named."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
Client(_add_server(), extensions=[_ObserverA(), _ObserverB()])
|
||||
|
||||
assert str(exc_info.value) == snapshot(
|
||||
"extensions 'com.example/observer-a' and 'com.example/observer-b' both bind "
|
||||
"notification method 'notifications/vendor/event'; a method can have only one observer"
|
||||
)
|
||||
|
||||
|
||||
# settings() consumption
|
||||
|
||||
|
||||
class _CountedResult(Result):
|
||||
result_type: Literal["counted"] = "counted"
|
||||
|
||||
|
||||
async def _unreachable_counted_resolve(claimed: _CountedResult, ctx: ClaimContext) -> CallToolResult:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class _CountingSettings(ClientExtension):
|
||||
identifier = "com.example/counted"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.reads = 0
|
||||
self.claims_reads = 0
|
||||
self.notifications_reads = 0
|
||||
|
||||
def settings(self) -> dict[str, Any]:
|
||||
self.reads += 1
|
||||
return {"read": self.reads}
|
||||
|
||||
def claims(self) -> Sequence[ResultClaim[Any]]:
|
||||
self.claims_reads += 1
|
||||
return [ResultClaim(result_type="counted", model=_CountedResult, resolve=_unreachable_counted_resolve)]
|
||||
|
||||
def notifications(self) -> Sequence[NotificationBinding[Any]]:
|
||||
self.notifications_reads += 1
|
||||
return [
|
||||
NotificationBinding(method="notifications/counted", params_type=_EventParams, handler=_unreachable_handler)
|
||||
]
|
||||
|
||||
|
||||
async def test_declarations_are_read_exactly_once_at_construction() -> None:
|
||||
"""SDK-defined: each declaration method is read exactly once, at Client construction, never again."""
|
||||
extension = _CountingSettings()
|
||||
client = Client(_add_server(), extensions=[extension])
|
||||
assert (extension.reads, extension.claims_reads, extension.notifications_reads) == (1, 1, 1)
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with client:
|
||||
await client.call_tool("add", {"a": 1, "b": 2})
|
||||
await client.call_tool("add", {"a": 3, "b": 4})
|
||||
|
||||
assert (extension.reads, extension.claims_reads, extension.notifications_reads) == (1, 1, 1)
|
||||
|
||||
|
||||
async def test_settings_dict_is_held_by_reference_not_copied() -> None:
|
||||
"""SDK-defined: the settings dict is held by reference, so mutating it before connect changes the ad."""
|
||||
observed: list[dict[str, dict[str, Any]] | None] = []
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name == "probe"
|
||||
assert ctx.session.client_params is not None
|
||||
observed.append(ctx.session.client_params.capabilities.extensions)
|
||||
return CallToolResult(content=[])
|
||||
|
||||
async def list_tools(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})])
|
||||
|
||||
server = Server("probe", on_call_tool=call_tool, on_list_tools=list_tools)
|
||||
settings = {"tier": "bronze"}
|
||||
client = Client(server, extensions=[advertise("com.example/loyalty", settings)])
|
||||
settings["tier"] = "gold"
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with client:
|
||||
await client.call_tool("probe", {})
|
||||
|
||||
assert observed == [{"com.example/loyalty": {"tier": "gold"}}]
|
||||
|
||||
|
||||
# extensions=None stays byte-identical
|
||||
|
||||
|
||||
@pytest.mark.parametrize("extensions", [None, ()], ids=["none", "empty"])
|
||||
async def test_no_extensions_keeps_tools_call_parsing_byte_identical(
|
||||
extensions: Sequence[ClientExtension] | None,
|
||||
) -> None:
|
||||
"""SDK-defined: `extensions=None` and an empty sequence leave the session exactly as a claim-less client's."""
|
||||
with anyio.fail_after(5):
|
||||
async with Client(_add_server(), extensions=extensions) as client:
|
||||
assert client.session._call_tool_adapter is _CallToolResultAdapter
|
||||
result = await client.call_tool("add", {"a": 1, "b": 2})
|
||||
|
||||
assert result.structured_content == {"result": 3}
|
||||
|
||||
|
||||
# The transparent claim path
|
||||
|
||||
|
||||
async def test_claimed_result_resolves_transparently_to_the_resolvers_result() -> None:
|
||||
"""A claimed shape never surfaces: the resolver gets the parsed model and `call_tool` returns its product."""
|
||||
received: list[VoucherResult] = []
|
||||
produced: list[CallToolResult] = []
|
||||
|
||||
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
|
||||
received.append(claimed)
|
||||
product = CallToolResult(content=[TextContent(text=f"honored {claimed.voucher_code}")])
|
||||
produced.append(product)
|
||||
return product
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client:
|
||||
result = await client.call_tool("issue", {})
|
||||
assert_type(result, CallToolResult)
|
||||
|
||||
assert [claimed.voucher_code for claimed in received] == ["v-42"]
|
||||
assert result is produced[0]
|
||||
assert result.content == [TextContent(text="honored v-42")]
|
||||
|
||||
|
||||
async def test_claimed_shape_routes_to_its_owning_extensions_resolver() -> None:
|
||||
"""With two claim-bearing extensions registered, the parsed shape runs its owner's resolver only."""
|
||||
received: list[VoucherResult] = []
|
||||
|
||||
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
|
||||
received.append(claimed)
|
||||
return CallToolResult(content=[TextContent(text="routed")])
|
||||
|
||||
extensions = [_CouponExtension(), _VoucherExtension(resolve)]
|
||||
with anyio.fail_after(5):
|
||||
async with Client(_voucher_server(), extensions=extensions) as client:
|
||||
result = await client.call_tool("issue", {})
|
||||
|
||||
assert [claimed.voucher_code for claimed in received] == ["v-42"]
|
||||
assert result.content == [TextContent(text="routed")]
|
||||
|
||||
|
||||
async def test_resolver_product_gets_the_direct_paths_output_schema_revalidation() -> None:
|
||||
"""The resolver's product is revalidated against the tool's output schema exactly like a direct result."""
|
||||
|
||||
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
|
||||
return CallToolResult(content=[TextContent(text="unstructured")])
|
||||
|
||||
async with Client(_structured_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client:
|
||||
with anyio.fail_after(5), pytest.raises(RuntimeError) as exc_info:
|
||||
await client.call_tool("issue", {})
|
||||
|
||||
assert str(exc_info.value) == snapshot("Tool issue has an output schema but did not return structured content")
|
||||
|
||||
|
||||
async def test_resolver_error_result_is_returned_not_raised() -> None:
|
||||
"""An `isError` resolver product skips output-schema revalidation and comes back as-is."""
|
||||
|
||||
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
|
||||
return CallToolResult(content=[TextContent(text="voucher printer on fire")], is_error=True)
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(_structured_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client:
|
||||
result = await client.call_tool("issue", {})
|
||||
|
||||
assert result.is_error
|
||||
assert result.content == [TextContent(text="voucher printer on fire")]
|
||||
|
||||
|
||||
async def test_resolver_receives_the_calls_claim_context() -> None:
|
||||
"""`ClaimContext` carries the client's own session object, the tool name, and the per-call read timeout."""
|
||||
contexts: list[ClaimContext] = []
|
||||
|
||||
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
|
||||
contexts.append(ctx)
|
||||
return CallToolResult(content=[])
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client:
|
||||
await client.call_tool("issue", {}, read_timeout_seconds=7.0)
|
||||
[ctx] = contexts
|
||||
assert ctx.session is client.session
|
||||
|
||||
assert ctx.tool_name == "issue"
|
||||
assert ctx.read_timeout_seconds == 7.0
|
||||
|
||||
|
||||
class _VoucherRefused(Exception):
|
||||
"""Extension-owned error vocabulary."""
|
||||
|
||||
|
||||
async def test_resolver_exception_propagates_untouched() -> None:
|
||||
"""A resolver exception reaches the `call_tool` caller as the very object raised, unwrapped."""
|
||||
refusal = _VoucherRefused("the voucher is refused")
|
||||
|
||||
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
|
||||
raise refusal
|
||||
|
||||
async with Client(_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client:
|
||||
with anyio.fail_after(5), pytest.raises(_VoucherRefused) as exc_info:
|
||||
await client.call_tool("issue", {})
|
||||
|
||||
assert exc_info.value is refusal
|
||||
|
||||
|
||||
# Unclaimed results with extensions present
|
||||
|
||||
|
||||
async def test_unclaimed_result_flows_through_unchanged_with_extensions_present() -> None:
|
||||
"""An ordinary `CallToolResult` is untouched by the claim machinery; the resolver never runs."""
|
||||
|
||||
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
|
||||
raise NotImplementedError # this server never produces a claimed shape
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(_add_server(), extensions=[_VoucherExtension(resolve)]) as client:
|
||||
result = await client.call_tool("add", {"a": 1, "b": 2})
|
||||
|
||||
assert result.structured_content == {"result": 3}
|
||||
|
||||
|
||||
async def test_input_required_then_plain_result_keeps_the_auto_loop_working() -> None:
|
||||
"""With a claim-bearing extension present, the input_required auto loop on an unclaimed tool is unchanged."""
|
||||
server = MCPServer("mrtr")
|
||||
|
||||
@server.tool()
|
||||
async def greet(ctx: Context) -> str | types.InputRequiredResult:
|
||||
responses = ctx.input_responses
|
||||
if responses and "user_name" in responses:
|
||||
answer = responses["user_name"]
|
||||
assert isinstance(answer, types.ElicitResult)
|
||||
assert answer.content is not None
|
||||
return f"Hello, {answer.content['name']}!"
|
||||
return types.InputRequiredResult(input_requests={"user_name": _name_elicitation()})
|
||||
|
||||
async def elicitation_callback(
|
||||
context: ClientRequestContext, params: types.ElicitRequestParams
|
||||
) -> types.ElicitResult | types.ErrorData:
|
||||
return types.ElicitResult(action="accept", content={"name": "Ada"})
|
||||
|
||||
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
|
||||
raise NotImplementedError # this server never produces a claimed shape
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(
|
||||
server, elicitation_callback=elicitation_callback, extensions=[_VoucherExtension(resolve)]
|
||||
) as client:
|
||||
result = await client.call_tool("greet")
|
||||
|
||||
assert result.content == [TextContent(text="Hello, Ada!")]
|
||||
|
||||
|
||||
# The multi-round-trip + claimed interplay
|
||||
|
||||
|
||||
async def test_input_required_then_claimed_result_on_retry_resolves_transparently() -> None:
|
||||
"""A call that demands input first and returns a claimed shape on the retry still resolves transparently."""
|
||||
prompted: list[str] = []
|
||||
received: list[VoucherResult] = []
|
||||
|
||||
async def elicitation_callback(
|
||||
context: ClientRequestContext, params: types.ElicitRequestParams
|
||||
) -> types.ElicitResult | types.ErrorData:
|
||||
assert isinstance(params, types.ElicitRequestFormParams)
|
||||
prompted.append(params.message)
|
||||
return types.ElicitResult(action="accept", content={"name": "Ada"})
|
||||
|
||||
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
|
||||
received.append(claimed)
|
||||
return CallToolResult(content=[TextContent(text=f"honored {claimed.voucher_code}")])
|
||||
|
||||
server = _voucher_server(issuer=_TwoRoundVoucherIssuer())
|
||||
with anyio.fail_after(5):
|
||||
async with Client(
|
||||
server, elicitation_callback=elicitation_callback, extensions=[_VoucherExtension(resolve)]
|
||||
) as client:
|
||||
result = await client.call_tool("issue", {})
|
||||
|
||||
assert prompted == ["What is your name?"]
|
||||
assert [claimed.voucher_code for claimed in received] == ["after-input"]
|
||||
assert result.content == [TextContent(text="honored after-input")]
|
||||
|
||||
|
||||
# Notification bindings fold into the session
|
||||
|
||||
|
||||
class _CoreMethodObserver(ClientExtension):
|
||||
"""Binds a method the modern core tables already define."""
|
||||
|
||||
identifier = "com.example/observer"
|
||||
|
||||
def notifications(self) -> Sequence[NotificationBinding[Any]]:
|
||||
return [
|
||||
NotificationBinding(method="notifications/message", params_type=_EventParams, handler=_unreachable_handler)
|
||||
]
|
||||
|
||||
|
||||
async def test_notification_bindings_fold_into_the_session(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""The Client threads extension bindings into its session; a core-known binding draws the one-time warning."""
|
||||
with caplog.at_level(logging.WARNING, logger="client"):
|
||||
async with Client(_add_server(), extensions=[_CoreMethodObserver()]):
|
||||
pass
|
||||
|
||||
expected = f"notification binding for 'notifications/message' will never fire at {LATEST_MODERN_VERSION}"
|
||||
assert caplog.text.count(expected) == 1
|
||||
@@ -0,0 +1,379 @@
|
||||
"""Construction-time tests for `mcp.client.extension`; no session is ever opened."""
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import CallToolResult, InputRequiredResult, Result
|
||||
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
|
||||
from pydantic import AliasChoices, AliasPath, BaseModel, Field
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
from mcp.client.extension import (
|
||||
ClaimContext,
|
||||
ClientExtension,
|
||||
NotificationBinding,
|
||||
ResultClaim,
|
||||
_wire_keys,
|
||||
advertise,
|
||||
)
|
||||
|
||||
|
||||
class _TaskResult(Result):
|
||||
result_type: Literal["task"] = "task"
|
||||
task_id: str = "t-1"
|
||||
|
||||
|
||||
class _UntaggedResult(Result):
|
||||
"""No `result_type` field at all."""
|
||||
|
||||
|
||||
class _PlainStringTagResult(Result):
|
||||
result_type: str = "task"
|
||||
|
||||
|
||||
class _OtherTagResult(Result):
|
||||
result_type: Literal["other"] = "other"
|
||||
|
||||
|
||||
class _ClaimedCallToolResult(CallToolResult):
|
||||
"""A core-result subclass; rejected as a claim model regardless of its tag."""
|
||||
|
||||
|
||||
class _ClaimedInputRequiredResult(InputRequiredResult):
|
||||
"""A core-result subclass; rejected as a claim model regardless of its tag."""
|
||||
|
||||
|
||||
async def _resolve(result: Result, ctx: ClaimContext) -> CallToolResult:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def _claim(model: type[Result] = _TaskResult, **kwargs: Any) -> ResultClaim[Result]:
|
||||
return ResultClaim(result_type="task", model=model, resolve=_resolve, **kwargs)
|
||||
|
||||
|
||||
def test_claim_with_literal_discriminated_model_constructs() -> None:
|
||||
"""SDK-defined: a model tagged with the claimed Literal constructs, defaulting to `tools/call` everywhere."""
|
||||
claim = ResultClaim(result_type="task", model=_TaskResult, resolve=_resolve)
|
||||
|
||||
assert claim.result_type == "task"
|
||||
assert claim.model is _TaskResult
|
||||
assert claim.resolve is _resolve
|
||||
assert claim.method == "tools/call"
|
||||
assert claim.protocol_versions is None
|
||||
|
||||
|
||||
def test_claim_accepts_modern_protocol_versions() -> None:
|
||||
"""SDK-defined: a non-None `protocol_versions` subset of the modern revisions is accepted."""
|
||||
versions = frozenset(MODERN_PROTOCOL_VERSIONS)
|
||||
|
||||
claim = _claim(protocol_versions=versions)
|
||||
|
||||
assert claim.protocol_versions == versions
|
||||
|
||||
|
||||
def test_claim_rejects_core_result_type_vocabulary() -> None:
|
||||
"""SDK-defined: a claim cannot re-key the core tags 'complete' and 'input_required'."""
|
||||
messages: dict[str, str] = {}
|
||||
for result_type in ("complete", "input_required"):
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
ResultClaim(result_type=result_type, model=_TaskResult, resolve=_resolve)
|
||||
messages[result_type] = str(exc_info.value)
|
||||
|
||||
assert messages == snapshot(
|
||||
{
|
||||
"complete": "resultType 'complete' is core protocol vocabulary",
|
||||
"input_required": "resultType 'input_required' is core protocol vocabulary",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", [_ClaimedCallToolResult, _ClaimedInputRequiredResult])
|
||||
def test_claim_rejects_model_subclassing_core_result_types(model: type[Result]) -> None:
|
||||
"""SDK-defined: a claim model subclassing a core result type is rejected; it would bypass claim routing."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
_claim(model=model)
|
||||
|
||||
assert str(exc_info.value) == snapshot("claim models must not subclass core result types")
|
||||
|
||||
|
||||
def test_claim_rejects_model_without_result_type_field() -> None:
|
||||
"""SDK-defined: the claim model must declare the discriminating `result_type` field."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
_claim(model=_UntaggedResult)
|
||||
|
||||
assert str(exc_info.value) == snapshot("_UntaggedResult.result_type must be Literal['task']")
|
||||
|
||||
|
||||
def test_claim_rejects_plain_str_result_type_field() -> None:
|
||||
"""SDK-defined: the model's `result_type` must be a Literal of the claimed tag, not a plain `str`."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
_claim(model=_PlainStringTagResult)
|
||||
|
||||
assert str(exc_info.value) == snapshot("_PlainStringTagResult.result_type must be Literal['task']")
|
||||
|
||||
|
||||
def test_claim_rejects_mismatched_result_type_literal() -> None:
|
||||
"""SDK-defined: the model's Literal tag must equal the claim's `result_type`."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
_claim(model=_OtherTagResult)
|
||||
|
||||
assert str(exc_info.value) == snapshot("_OtherTagResult.result_type must be Literal['task']")
|
||||
|
||||
|
||||
class _NotAResult(BaseModel):
|
||||
result_type: Literal["plain"] = "plain"
|
||||
|
||||
|
||||
class _ReservedAliasResult(Result):
|
||||
result_type: Literal["clash"] = "clash"
|
||||
request_state: dict[str, Any] = {}
|
||||
|
||||
|
||||
def test_claim_rejects_model_not_subclassing_result() -> None:
|
||||
"""SDK-defined: a plain BaseModel cannot be a claim model; the session returns `Result` values."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
ResultClaim(result_type="plain", model=cast("type[Result]", _NotAResult), resolve=_resolve)
|
||||
|
||||
assert str(exc_info.value) == snapshot("_NotAResult must subclass mcp_types.Result")
|
||||
|
||||
|
||||
def test_claim_rejects_model_aliasing_core_surface_fields() -> None:
|
||||
"""SDK-defined: a field aliasing requestState or inputRequests would fail core pre-validation."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
ResultClaim(result_type="clash", model=_ReservedAliasResult, resolve=_resolve)
|
||||
|
||||
assert str(exc_info.value) == snapshot(
|
||||
"_ReservedAliasResult.request_state aliases 'requestState', a typed field of the core "
|
||||
"result surface; a colliding value would fail core validation before the claim adapter runs"
|
||||
)
|
||||
|
||||
|
||||
class _ValidationAliasResult(Result):
|
||||
result_type: Literal["va"] = "va"
|
||||
vendor_state: dict[str, Any] | None = Field(default=None, validation_alias="requestState")
|
||||
|
||||
|
||||
class _SerializationAliasResult(Result):
|
||||
result_type: Literal["sa"] = "sa"
|
||||
vendor_state: dict[str, Any] | None = Field(default=None, serialization_alias="inputRequests")
|
||||
|
||||
|
||||
class _AliasChoicesResult(Result):
|
||||
result_type: Literal["ac"] = "ac"
|
||||
vendor_state: dict[str, Any] | None = Field(
|
||||
default=None, validation_alias=AliasChoices("vendorKey", "requestState")
|
||||
)
|
||||
|
||||
|
||||
class _AliasPathResult(Result):
|
||||
result_type: Literal["ap"] = "ap"
|
||||
vendor_state: dict[str, Any] | None = Field(
|
||||
default=None, validation_alias=AliasChoices(AliasPath("requestState", "nested"))
|
||||
)
|
||||
|
||||
|
||||
def test_wire_keys_for_a_bare_field_is_just_its_name() -> None:
|
||||
"""SDK-defined: a field with no aliases reads and writes only its own name."""
|
||||
assert _wire_keys("plain", FieldInfo(annotation=str)) == frozenset({"plain"})
|
||||
|
||||
|
||||
def test_claim_rejects_reserved_aliases_in_every_alias_form() -> None:
|
||||
"""SDK-defined: validation_alias, serialization_alias, and AliasChoices routes to a reserved key are all caught."""
|
||||
messages: dict[str, str] = {}
|
||||
for model in (_ValidationAliasResult, _SerializationAliasResult, _AliasChoicesResult, _AliasPathResult):
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
ResultClaim(result_type=model.model_fields["result_type"].default, model=model, resolve=_resolve)
|
||||
messages[model.__name__] = str(exc_info.value)
|
||||
|
||||
assert messages == snapshot(
|
||||
{
|
||||
"_ValidationAliasResult": "_ValidationAliasResult.vendor_state aliases "
|
||||
"'requestState', a typed field of the core result surface; a colliding value would fail "
|
||||
"core validation before the claim adapter runs",
|
||||
"_SerializationAliasResult": "_SerializationAliasResult.vendor_state aliases "
|
||||
"'inputRequests', a typed field of the core result surface; a colliding value would fail "
|
||||
"core validation before the claim adapter runs",
|
||||
"_AliasChoicesResult": "_AliasChoicesResult.vendor_state aliases 'requestState', a typed field of the core "
|
||||
"result surface; a colliding value would fail core validation before the claim adapter runs",
|
||||
"_AliasPathResult": "_AliasPathResult.vendor_state aliases "
|
||||
"'requestState', a typed field of the core result surface; a colliding value would fail "
|
||||
"core validation before the claim adapter runs",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_claim_rejects_method_outside_the_closed_verb_set() -> None:
|
||||
"""SDK-defined: claims attach to `tools/call` only, even for values that dodge the static Literal gate."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
_claim(method=cast("Literal['tools/call']", "prompts/get"))
|
||||
|
||||
assert str(exc_info.value) == snapshot("claims attach to ['tools/call'] only; got method 'prompts/get'")
|
||||
|
||||
|
||||
def test_claim_rejects_empty_protocol_versions() -> None:
|
||||
"""SDK-defined: an empty version set is rejected; `None` is the spelling for every modern version."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
_claim(protocol_versions=frozenset())
|
||||
|
||||
assert str(exc_info.value) == snapshot("empty protocol_versions could never activate; use None for all")
|
||||
|
||||
|
||||
def test_claim_rejects_non_modern_protocol_versions() -> None:
|
||||
"""SDK-defined: a non-None version set must be a subset of the modern protocol revisions."""
|
||||
messages: list[str] = []
|
||||
for versions in (
|
||||
frozenset({"2025-11-25"}),
|
||||
frozenset({"2026-07-28", "2025-11-25"}),
|
||||
frozenset({"never-a-version"}),
|
||||
):
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
_claim(protocol_versions=versions)
|
||||
messages.append(str(exc_info.value))
|
||||
|
||||
assert messages == snapshot(
|
||||
[
|
||||
"protocol_versions ['2025-11-25'] are not modern protocol revisions; claimed shapes "
|
||||
"cannot be delivered on a legacy wire (None means every modern version)",
|
||||
"protocol_versions ['2025-11-25'] are not modern protocol revisions; claimed shapes "
|
||||
"cannot be delivered on a legacy wire (None means every modern version)",
|
||||
"protocol_versions ['never-a-version'] are not modern protocol revisions; claimed shapes "
|
||||
"cannot be delivered on a legacy wire (None means every modern version)",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_result_claim_is_frozen() -> None:
|
||||
"""SDK-defined: claims are immutable; mutating one after construction raises."""
|
||||
claim = _claim()
|
||||
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
setattr(claim, "result_type", "other") # direct assignment is also a type error
|
||||
|
||||
|
||||
class _TaskNotificationParams(BaseModel):
|
||||
task_id: str
|
||||
|
||||
|
||||
async def _on_task(params: _TaskNotificationParams) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def test_notification_binding_constructs() -> None:
|
||||
"""SDK-defined: a binding is a bare declaration with no construction-time validation."""
|
||||
binding = NotificationBinding(method="notifications/tasks", params_type=_TaskNotificationParams, handler=_on_task)
|
||||
|
||||
assert binding.method == "notifications/tasks"
|
||||
assert binding.params_type is _TaskNotificationParams
|
||||
assert binding.handler is _on_task
|
||||
|
||||
|
||||
def test_notification_binding_accepts_core_known_method() -> None:
|
||||
"""SDK-defined: deliberately no spec-table check at construction, so packages survive core adopting a method."""
|
||||
binding = NotificationBinding(
|
||||
method="notifications/progress", params_type=_TaskNotificationParams, handler=_on_task
|
||||
)
|
||||
|
||||
assert binding.method == "notifications/progress"
|
||||
|
||||
|
||||
def test_notification_binding_is_frozen() -> None:
|
||||
"""SDK-defined: bindings are immutable; mutating one after construction raises."""
|
||||
binding = NotificationBinding(method="notifications/tasks", params_type=_TaskNotificationParams, handler=_on_task)
|
||||
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
setattr(binding, "method", "notifications/other") # direct assignment is also a type error
|
||||
|
||||
|
||||
def test_extension_defaults_advertise_nothing() -> None:
|
||||
"""SDK-defined: a minimal subclass advertises empty settings, no claims, and no bindings."""
|
||||
|
||||
class _MinimalExt(ClientExtension):
|
||||
identifier = "com.example/minimal"
|
||||
|
||||
ext = _MinimalExt()
|
||||
|
||||
assert ext.settings() == {}
|
||||
assert ext.claims() == ()
|
||||
assert ext.notifications() == ()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"identifier",
|
||||
[
|
||||
"io.modelcontextprotocol/ui",
|
||||
"com.example/my_ext",
|
||||
"com.x-y.z2/n.a-b_c",
|
||||
"example/x",
|
||||
"a/b",
|
||||
"com.example/9start",
|
||||
],
|
||||
)
|
||||
def test_grammar_conformant_identifiers_accepted_at_class_definition(identifier: str) -> None:
|
||||
"""Spec `_meta` key grammar: conformant `vendor-prefix/name` identifiers are accepted."""
|
||||
cls = type("_GoodExt", (ClientExtension,), {"identifier": identifier})
|
||||
|
||||
assert cls.identifier == identifier
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"identifier",
|
||||
[
|
||||
"noprefix",
|
||||
"-foo/bar",
|
||||
".leading/x",
|
||||
"a..b/x",
|
||||
"foo-/x",
|
||||
"9foo/x",
|
||||
"foo/-bar",
|
||||
"foo/bar-",
|
||||
"foo/",
|
||||
"/bar",
|
||||
"foo/ba r",
|
||||
"io.modelcontextprotocol/ui\n",
|
||||
"",
|
||||
42,
|
||||
],
|
||||
)
|
||||
def test_malformed_identifier_rejected_at_class_definition(identifier: Any) -> None:
|
||||
"""SDK-defined: the SEP-2133 `vendor-prefix/name` grammar is enforced the moment the subclass is defined."""
|
||||
with pytest.raises(TypeError):
|
||||
type("_BadExt", (ClientExtension,), {"identifier": identifier})
|
||||
|
||||
|
||||
def test_subclass_without_identifier_allowed_at_definition() -> None:
|
||||
"""SDK-defined: a subclass with no class-level `identifier` is allowed; validation waits for consumption."""
|
||||
|
||||
class _AbstractishExt(ClientExtension):
|
||||
"""Intermediate base; concrete subclasses supply the identifier."""
|
||||
|
||||
class _ConcreteExt(_AbstractishExt):
|
||||
identifier = "com.example/concrete"
|
||||
|
||||
assert _ConcreteExt.identifier == "com.example/concrete"
|
||||
|
||||
|
||||
def test_advertise_serves_captured_settings() -> None:
|
||||
"""SDK-defined: `advertise()` returns an ad-only extension serving the captured settings."""
|
||||
ext = advertise("com.example/flags", {"enabled": True})
|
||||
|
||||
assert isinstance(ext, ClientExtension)
|
||||
assert ext.identifier == "com.example/flags"
|
||||
assert ext.settings() == {"enabled": True}
|
||||
assert ext.claims() == ()
|
||||
assert ext.notifications() == ()
|
||||
|
||||
|
||||
def test_advertise_defaults_to_empty_settings() -> None:
|
||||
"""SDK-defined: omitting settings advertises the extension with an empty map."""
|
||||
ext = advertise("com.example/flags")
|
||||
|
||||
assert ext.settings() == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("identifier", ["noprefix", "foo/", ""])
|
||||
def test_advertise_validates_identifier_eagerly(identifier: str) -> None:
|
||||
"""SDK-defined: `advertise()` validates the identifier eagerly, at the call site."""
|
||||
with pytest.raises(TypeError):
|
||||
advertise(identifier)
|
||||
@@ -0,0 +1,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世界🌍Привет안녕مرحباשלום"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
@@ -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"})
|
||||
@@ -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"})]
|
||||
)
|
||||
@@ -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]
|
||||
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user