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) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:10:27 +08:00
commit 49b9bb6724
992 changed files with 161690 additions and 0 deletions
View File
View File
@@ -0,0 +1,119 @@
"""Tests for the AuthContext middleware components."""
import time
import pytest
from starlette.types import Message, Receive, Scope, Send
from mcp.server.auth.middleware.auth_context import (
AuthContextMiddleware,
auth_context_var,
get_access_token,
)
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
from mcp.server.auth.provider import AccessToken
class MockApp:
"""Mock ASGI app for testing."""
def __init__(self):
self.called = False
self.scope: Scope | None = None
self.receive: Receive | None = None
self.send: Send | None = None
self.access_token_during_call: AccessToken | None = None
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
self.called = True
self.scope = scope
self.receive = receive
self.send = send
# Check the context during the call
self.access_token_during_call = get_access_token()
@pytest.fixture
def valid_access_token() -> AccessToken:
"""Create a valid access token."""
return AccessToken(
token="valid_token",
client_id="test_client",
scopes=["read", "write"],
expires_at=int(time.time()) + 3600, # 1 hour from now
)
@pytest.mark.anyio
async def test_auth_context_middleware_with_authenticated_user(valid_access_token: AccessToken):
"""Test middleware with an authenticated user in scope."""
app = MockApp()
middleware = AuthContextMiddleware(app)
# Create an authenticated user
user = AuthenticatedUser(valid_access_token)
scope: Scope = {"type": "http", "user": user}
# Create dummy async functions for receive and send
async def receive() -> Message: # pragma: no cover
return {"type": "http.request"}
async def send(message: Message) -> None: # pragma: no cover
pass
# Verify context is empty before middleware
assert auth_context_var.get() is None
assert get_access_token() is None
# Run the middleware
await middleware(scope, receive, send)
# Verify the app was called
assert app.called
assert app.scope == scope
assert app.receive == receive
assert app.send == send
# Verify the access token was available during the call
assert app.access_token_during_call == valid_access_token
# Verify context is reset after middleware
assert auth_context_var.get() is None
assert get_access_token() is None
@pytest.mark.anyio
async def test_auth_context_middleware_with_no_user():
"""Test middleware with no user in scope."""
app = MockApp()
middleware = AuthContextMiddleware(app)
scope: Scope = {"type": "http"} # No user
# Create dummy async functions for receive and send
async def receive() -> Message: # pragma: no cover
return {"type": "http.request"}
async def send(message: Message) -> None: # pragma: no cover
pass
# Verify context is empty before middleware
assert auth_context_var.get() is None
assert get_access_token() is None
# Run the middleware
await middleware(scope, receive, send)
# Verify the app was called
assert app.called
assert app.scope == scope
assert app.receive == receive
assert app.send == send
# Verify the access token was not available during the call
assert app.access_token_during_call is None
# Verify context is still empty after middleware
assert auth_context_var.get() is None
assert get_access_token() is None
@@ -0,0 +1,471 @@
"""Tests for the BearerAuth middleware components."""
import time
from typing import Any, cast
import pytest
from starlette.authentication import AuthCredentials
from starlette.datastructures import Headers
from starlette.requests import Request
from starlette.types import Message, Receive, Scope, Send
from mcp.server.auth.middleware.bearer_auth import (
AuthenticatedUser,
BearerAuthBackend,
RequireAuthMiddleware,
authorization_context,
)
from mcp.server.auth.provider import (
AccessToken,
OAuthAuthorizationServerProvider,
ProviderTokenVerifier,
principal_components,
)
class MockOAuthProvider:
"""Mock OAuth provider for testing.
This is a simplified version that only implements the methods needed for testing
the BearerAuthMiddleware components.
"""
def __init__(self):
self.tokens: dict[str, AccessToken] = {} # token -> AccessToken
def add_token(self, token: str, access_token: AccessToken) -> None:
"""Add a token to the provider."""
self.tokens[token] = access_token
async def load_access_token(self, token: str) -> AccessToken | None:
"""Load an access token."""
return self.tokens.get(token)
def add_token_to_provider(
provider: OAuthAuthorizationServerProvider[Any, Any, Any],
token: str,
access_token: AccessToken,
) -> None:
"""Helper function to add a token to a provider.
This is used to work around type checking issues with our mock provider.
"""
# We know this is actually a MockOAuthProvider
mock_provider = cast(MockOAuthProvider, provider)
mock_provider.add_token(token, access_token)
class MockApp:
"""Mock ASGI app for testing."""
def __init__(self):
self.called = False
self.scope: Scope | None = None
self.receive: Receive | None = None
self.send: Send | None = None
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
self.called = True
self.scope = scope
self.receive = receive
self.send = send
@pytest.fixture
def mock_oauth_provider() -> OAuthAuthorizationServerProvider[Any, Any, Any]:
"""Create a mock OAuth provider."""
# Use type casting to satisfy the type checker
return cast(OAuthAuthorizationServerProvider[Any, Any, Any], MockOAuthProvider())
@pytest.fixture
def valid_access_token() -> AccessToken:
"""Create a valid access token."""
return AccessToken(
token="valid_token",
client_id="test_client",
scopes=["read", "write"],
expires_at=int(time.time()) + 3600, # 1 hour from now
)
@pytest.fixture
def expired_access_token() -> AccessToken:
"""Create an expired access token."""
return AccessToken(
token="expired_token",
client_id="test_client",
scopes=["read"],
expires_at=int(time.time()) - 3600, # 1 hour ago
)
@pytest.fixture
def no_expiry_access_token() -> AccessToken:
"""Create an access token with no expiry."""
return AccessToken(
token="no_expiry_token",
client_id="test_client",
scopes=["read", "write"],
expires_at=None,
)
@pytest.mark.anyio
class TestBearerAuthBackend:
"""Tests for the BearerAuthBackend class."""
async def test_no_auth_header(self, mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any]):
"""Test authentication with no Authorization header."""
backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider))
request = Request({"type": "http", "headers": []})
result = await backend.authenticate(request)
assert result is None
async def test_non_bearer_auth_header(self, mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any]):
"""Test authentication with non-Bearer Authorization header."""
backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider))
request = Request(
{
"type": "http",
"headers": [(b"authorization", b"Basic dXNlcjpwYXNz")],
}
)
result = await backend.authenticate(request)
assert result is None
async def test_invalid_token(self, mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any]):
"""Test authentication with invalid token."""
backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider))
request = Request(
{
"type": "http",
"headers": [(b"authorization", b"Bearer invalid_token")],
}
)
result = await backend.authenticate(request)
assert result is None
async def test_expired_token(
self,
mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any],
expired_access_token: AccessToken,
):
"""Test authentication with expired token."""
backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider))
add_token_to_provider(mock_oauth_provider, "expired_token", expired_access_token)
request = Request(
{
"type": "http",
"headers": [(b"authorization", b"Bearer expired_token")],
}
)
result = await backend.authenticate(request)
assert result is None
async def test_valid_token(
self,
mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any],
valid_access_token: AccessToken,
):
"""Test authentication with valid token."""
backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider))
add_token_to_provider(mock_oauth_provider, "valid_token", valid_access_token)
request = Request(
{
"type": "http",
"headers": [(b"authorization", b"Bearer valid_token")],
}
)
result = await backend.authenticate(request)
assert result is not None
credentials, user = result
assert isinstance(credentials, AuthCredentials)
assert isinstance(user, AuthenticatedUser)
assert credentials.scopes == ["read", "write"]
assert user.display_name == "test_client"
assert user.access_token == valid_access_token
assert user.scopes == ["read", "write"]
async def test_token_without_expiry(
self,
mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any],
no_expiry_access_token: AccessToken,
):
"""Test authentication with token that has no expiry."""
backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider))
add_token_to_provider(mock_oauth_provider, "no_expiry_token", no_expiry_access_token)
request = Request(
{
"type": "http",
"headers": [(b"authorization", b"Bearer no_expiry_token")],
}
)
result = await backend.authenticate(request)
assert result is not None
credentials, user = result
assert isinstance(credentials, AuthCredentials)
assert isinstance(user, AuthenticatedUser)
assert credentials.scopes == ["read", "write"]
assert user.display_name == "test_client"
assert user.access_token == no_expiry_access_token
assert user.scopes == ["read", "write"]
async def test_lowercase_bearer_prefix(
self,
mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any],
valid_access_token: AccessToken,
):
"""Test with lowercase 'bearer' prefix in Authorization header"""
backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider))
add_token_to_provider(mock_oauth_provider, "valid_token", valid_access_token)
headers = Headers({"Authorization": "bearer valid_token"})
scope = {"type": "http", "headers": headers.raw}
request = Request(scope)
result = await backend.authenticate(request)
assert result is not None
credentials, user = result
assert isinstance(credentials, AuthCredentials)
assert isinstance(user, AuthenticatedUser)
assert credentials.scopes == ["read", "write"]
assert user.display_name == "test_client"
assert user.access_token == valid_access_token
async def test_mixed_case_bearer_prefix(
self,
mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any],
valid_access_token: AccessToken,
):
"""Test with mixed 'BeArEr' prefix in Authorization header"""
backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider))
add_token_to_provider(mock_oauth_provider, "valid_token", valid_access_token)
headers = Headers({"authorization": "BeArEr valid_token"})
scope = {"type": "http", "headers": headers.raw}
request = Request(scope)
result = await backend.authenticate(request)
assert result is not None
credentials, user = result
assert isinstance(credentials, AuthCredentials)
assert isinstance(user, AuthenticatedUser)
assert credentials.scopes == ["read", "write"]
assert user.display_name == "test_client"
assert user.access_token == valid_access_token
async def test_mixed_case_authorization_header(
self,
mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any],
valid_access_token: AccessToken,
):
"""Test authentication with mixed 'Authorization' header."""
backend = BearerAuthBackend(token_verifier=ProviderTokenVerifier(mock_oauth_provider))
add_token_to_provider(mock_oauth_provider, "valid_token", valid_access_token)
headers = Headers({"AuThOrIzAtIoN": "BeArEr valid_token"})
scope = {"type": "http", "headers": headers.raw}
request = Request(scope)
result = await backend.authenticate(request)
assert result is not None
credentials, user = result
assert isinstance(credentials, AuthCredentials)
assert isinstance(user, AuthenticatedUser)
assert credentials.scopes == ["read", "write"]
assert user.display_name == "test_client"
assert user.access_token == valid_access_token
@pytest.mark.anyio
class TestRequireAuthMiddleware:
"""Tests for the RequireAuthMiddleware class."""
async def test_no_user(self):
"""Test middleware with no user in scope."""
app = MockApp()
middleware = RequireAuthMiddleware(app, required_scopes=["read"])
scope: Scope = {"type": "http"}
# Create dummy async functions for receive and send
async def receive() -> Message: # pragma: no cover
return {"type": "http.request"}
sent_messages: list[Message] = []
async def send(message: Message) -> None:
sent_messages.append(message)
await middleware(scope, receive, send)
# Check that a 401 response was sent
assert len(sent_messages) == 2
assert sent_messages[0]["type"] == "http.response.start"
assert sent_messages[0]["status"] == 401
assert any(h[0] == b"www-authenticate" for h in sent_messages[0]["headers"])
assert not app.called
async def test_non_authenticated_user(self):
"""Test middleware with non-authenticated user in scope."""
app = MockApp()
middleware = RequireAuthMiddleware(app, required_scopes=["read"])
scope: Scope = {"type": "http", "user": object()}
# Create dummy async functions for receive and send
async def receive() -> Message: # pragma: no cover
return {"type": "http.request"}
sent_messages: list[Message] = []
async def send(message: Message) -> None:
sent_messages.append(message)
await middleware(scope, receive, send)
# Check that a 401 response was sent
assert len(sent_messages) == 2
assert sent_messages[0]["type"] == "http.response.start"
assert sent_messages[0]["status"] == 401
assert any(h[0] == b"www-authenticate" for h in sent_messages[0]["headers"])
assert not app.called
async def test_missing_required_scope(self, valid_access_token: AccessToken):
"""Test middleware with user missing required scope."""
app = MockApp()
middleware = RequireAuthMiddleware(app, required_scopes=["admin"])
# Create a user with read/write scopes but not admin
user = AuthenticatedUser(valid_access_token)
auth = AuthCredentials(["read", "write"])
scope: Scope = {"type": "http", "user": user, "auth": auth}
# Create dummy async functions for receive and send
async def receive() -> Message: # pragma: no cover
return {"type": "http.request"}
sent_messages: list[Message] = []
async def send(message: Message) -> None:
sent_messages.append(message)
await middleware(scope, receive, send)
# Check that a 403 response was sent
assert len(sent_messages) == 2
assert sent_messages[0]["type"] == "http.response.start"
assert sent_messages[0]["status"] == 403
assert any(h[0] == b"www-authenticate" for h in sent_messages[0]["headers"])
assert not app.called
async def test_no_auth_credentials(self, valid_access_token: AccessToken):
"""Test middleware with no auth credentials in scope."""
app = MockApp()
middleware = RequireAuthMiddleware(app, required_scopes=["read"])
# Create a user with read/write scopes
user = AuthenticatedUser(valid_access_token)
scope: Scope = {"type": "http", "user": user} # No auth credentials
# Create dummy async functions for receive and send
async def receive() -> Message: # pragma: no cover
return {"type": "http.request"}
sent_messages: list[Message] = []
async def send(message: Message) -> None:
sent_messages.append(message)
await middleware(scope, receive, send)
# Check that a 403 response was sent
assert len(sent_messages) == 2
assert sent_messages[0]["type"] == "http.response.start"
assert sent_messages[0]["status"] == 403
assert any(h[0] == b"www-authenticate" for h in sent_messages[0]["headers"])
assert not app.called
async def test_has_required_scopes(self, valid_access_token: AccessToken):
"""Test middleware with user having all required scopes."""
app = MockApp()
middleware = RequireAuthMiddleware(app, required_scopes=["read"])
# Create a user with read/write scopes
user = AuthenticatedUser(valid_access_token)
auth = AuthCredentials(["read", "write"])
scope: Scope = {"type": "http", "user": user, "auth": auth}
# Create dummy async functions for receive and send
async def receive() -> Message: # pragma: no cover
return {"type": "http.request"}
async def send(message: Message) -> None: # pragma: no cover
pass
await middleware(scope, receive, send)
assert app.called
assert app.scope == scope
assert app.receive == receive
assert app.send == send
async def test_multiple_required_scopes(self, valid_access_token: AccessToken):
"""Test middleware with multiple required scopes."""
app = MockApp()
middleware = RequireAuthMiddleware(app, required_scopes=["read", "write"])
# Create a user with read/write scopes
user = AuthenticatedUser(valid_access_token)
auth = AuthCredentials(["read", "write"])
scope: Scope = {"type": "http", "user": user, "auth": auth}
# Create dummy async functions for receive and send
async def receive() -> Message: # pragma: no cover
return {"type": "http.request"}
async def send(message: Message) -> None: # pragma: no cover
pass
await middleware(scope, receive, send)
assert app.called
assert app.scope == scope
assert app.receive == receive
assert app.send == send
async def test_no_required_scopes(self, valid_access_token: AccessToken):
"""Test middleware with no required scopes."""
app = MockApp()
middleware = RequireAuthMiddleware(app, required_scopes=[])
# Create a user with read/write scopes
user = AuthenticatedUser(valid_access_token)
auth = AuthCredentials(["read", "write"])
scope: Scope = {"type": "http", "user": user, "auth": auth}
# Create dummy async functions for receive and send
async def receive() -> Message: # pragma: no cover
return {"type": "http.request"}
async def send(message: Message) -> None: # pragma: no cover
pass
await middleware(scope, receive, send)
assert app.called
assert app.scope == scope
assert app.receive == receive
assert app.send == send
def test_authorization_context_is_built_from_principal_components() -> None:
"""Session ownership identifies the principal via the shared principal_components triple."""
token = AccessToken(
token="t", client_id="client-1", scopes=[], subject="alice", claims={"iss": "https://as.example"}
)
client_id, issuer, subject = principal_components(token)
assert authorization_context(AuthenticatedUser(token)) == {
"client_id": client_id,
"issuer": issuer,
"subject": subject,
}
+290
View File
@@ -0,0 +1,290 @@
"""Tests for OAuth error handling in the auth handlers."""
import base64
import hashlib
import secrets
import unittest.mock
from typing import Any
from urllib.parse import parse_qs, urlparse
import httpx
import pytest
from httpx import ASGITransport
from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from mcp.server.auth.provider import AuthorizeError, RegistrationError, TokenError
from mcp.server.auth.routes import create_auth_routes
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
from tests.server.mcpserver.auth.test_auth_integration import MockOAuthProvider
@pytest.fixture
def oauth_provider():
"""Return a MockOAuthProvider instance that can be configured to raise errors."""
return MockOAuthProvider()
@pytest.fixture
def app(oauth_provider: MockOAuthProvider):
# Enable client registration
client_registration_options = ClientRegistrationOptions(enabled=True)
revocation_options = RevocationOptions(enabled=True)
# Create auth routes
auth_routes = create_auth_routes(
oauth_provider,
issuer_url=AnyHttpUrl("http://localhost"),
client_registration_options=client_registration_options,
revocation_options=revocation_options,
)
# Create Starlette app with routes directly
return Starlette(routes=auth_routes)
@pytest.fixture
def client(app: Starlette):
transport = ASGITransport(app=app)
# Use base_url without a path since routes are directly on the app
return httpx.AsyncClient(transport=transport, base_url="http://localhost")
@pytest.fixture
def pkce_challenge():
"""Create a PKCE challenge with code_verifier and code_challenge."""
# Generate a code verifier
code_verifier = secrets.token_urlsafe(64)[:128]
# Create code challenge using S256 method
code_verifier_bytes = code_verifier.encode("ascii")
sha256 = hashlib.sha256(code_verifier_bytes).digest()
code_challenge = base64.urlsafe_b64encode(sha256).decode().rstrip("=")
return {"code_verifier": code_verifier, "code_challenge": code_challenge}
@pytest.fixture
async def registered_client(client: httpx.AsyncClient) -> dict[str, Any]:
"""Create and register a test client."""
# Default client metadata
client_metadata = {
"redirect_uris": ["https://client.example.com/callback"],
"token_endpoint_auth_method": "client_secret_post",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"client_name": "Test Client",
}
response = await client.post("/register", json=client_metadata)
assert response.status_code == 201, f"Failed to register client: {response.content}"
client_info = response.json()
return client_info
@pytest.mark.anyio
async def test_registration_error_handling(client: httpx.AsyncClient, oauth_provider: MockOAuthProvider):
# Mock the register_client method to raise a registration error
with unittest.mock.patch.object(
oauth_provider,
"register_client",
side_effect=RegistrationError(
error="invalid_redirect_uri",
error_description="The redirect URI is invalid",
),
):
# Prepare a client registration request
client_data = {
"redirect_uris": ["https://client.example.com/callback"],
"token_endpoint_auth_method": "client_secret_post",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"client_name": "Test Client",
}
# Send the registration request
response = await client.post(
"/register",
json=client_data,
)
# Verify the response
assert response.status_code == 400, response.content
data = response.json()
assert data["error"] == "invalid_redirect_uri"
assert data["error_description"] == "The redirect URI is invalid"
@pytest.mark.anyio
async def test_authorize_error_handling(
client: httpx.AsyncClient,
oauth_provider: MockOAuthProvider,
registered_client: dict[str, Any],
pkce_challenge: dict[str, str],
):
# Mock the authorize method to raise an authorize error
with unittest.mock.patch.object(
oauth_provider,
"authorize",
side_effect=AuthorizeError(error="access_denied", error_description="The user denied the request"),
):
# Register the client
client_id = registered_client["client_id"]
redirect_uri = registered_client["redirect_uris"][0]
# Prepare an authorization request
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"code_challenge": pkce_challenge["code_challenge"],
"code_challenge_method": "S256",
"state": "test_state",
}
# Send the authorization request
response = await client.get("/authorize", params=params)
# Verify the response is a redirect with error parameters
assert response.status_code == 302
redirect_url = response.headers["location"]
parsed_url = urlparse(redirect_url)
query_params = parse_qs(parsed_url.query)
assert query_params["error"][0] == "access_denied"
assert "error_description" in query_params
assert query_params["state"][0] == "test_state"
@pytest.mark.anyio
async def test_token_error_handling_auth_code(
client: httpx.AsyncClient,
oauth_provider: MockOAuthProvider,
registered_client: dict[str, Any],
pkce_challenge: dict[str, str],
):
# Register the client and get an auth code
client_id = registered_client["client_id"]
client_secret = registered_client["client_secret"]
redirect_uri = registered_client["redirect_uris"][0]
# First get an authorization code
auth_response = await client.get(
"/authorize",
params={
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"code_challenge": pkce_challenge["code_challenge"],
"code_challenge_method": "S256",
"state": "test_state",
},
)
redirect_url = auth_response.headers["location"]
parsed_url = urlparse(redirect_url)
query_params = parse_qs(parsed_url.query)
code = query_params["code"][0]
# Mock the exchange_authorization_code method to raise a token error
with unittest.mock.patch.object(
oauth_provider,
"exchange_authorization_code",
side_effect=TokenError(
error="invalid_grant",
error_description="The authorization code is invalid",
),
):
# Try to exchange the code for tokens
token_response = await client.post(
"/token",
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": client_id,
"client_secret": client_secret,
"code_verifier": pkce_challenge["code_verifier"],
},
)
# Verify the response
assert token_response.status_code == 400
data = token_response.json()
assert data["error"] == "invalid_grant"
assert data["error_description"] == "The authorization code is invalid"
@pytest.mark.anyio
async def test_token_error_handling_refresh_token(
client: httpx.AsyncClient,
oauth_provider: MockOAuthProvider,
registered_client: dict[str, Any],
pkce_challenge: dict[str, str],
):
# Register the client and get tokens
client_id = registered_client["client_id"]
client_secret = registered_client["client_secret"]
redirect_uri = registered_client["redirect_uris"][0]
# First get an authorization code
auth_response = await client.get(
"/authorize",
params={
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"code_challenge": pkce_challenge["code_challenge"],
"code_challenge_method": "S256",
"state": "test_state",
},
)
assert auth_response.status_code == 302, auth_response.content
redirect_url = auth_response.headers["location"]
parsed_url = urlparse(redirect_url)
query_params = parse_qs(parsed_url.query)
code = query_params["code"][0]
# Exchange the code for tokens
token_response = await client.post(
"/token",
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": client_id,
"client_secret": client_secret,
"code_verifier": pkce_challenge["code_verifier"],
},
)
tokens = token_response.json()
refresh_token = tokens["refresh_token"]
# Mock the exchange_refresh_token method to raise a token error
with unittest.mock.patch.object(
oauth_provider,
"exchange_refresh_token",
side_effect=TokenError(
error="invalid_scope",
error_description="The requested scope is invalid",
),
):
# Try to use the refresh token
refresh_response = await client.post(
"/token",
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": client_id,
"client_secret": client_secret,
},
)
# Verify the response
assert refresh_response.status_code == 400
data = refresh_response.json()
assert data["error"] == "invalid_scope"
assert data["error_description"] == "The requested scope is invalid"
@@ -0,0 +1,397 @@
"""Server-side SEP-990 Identity Assertion Authorization Grant (RFC 7523 jwt-bearer) handling."""
import secrets
import time
import httpx
import pytest
from httpx import ASGITransport
from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from mcp.server.auth.provider import (
AccessToken,
AuthorizationCode,
AuthorizationParams,
IdentityAssertionParams,
OAuthAuthorizationServerProvider,
RefreshToken,
TokenError,
)
from mcp.server.auth.routes import build_metadata, create_auth_routes
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken
ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag"
VALID_ASSERTION = "valid-id-jag"
CONFIDENTIAL_CLIENT_ID = "enterprise-client"
CONFIDENTIAL_CLIENT_SECRET = "enterprise-secret"
class IdentityAssertionProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]):
"""A provider that implements `exchange_identity_assertion`; everything else is unused here."""
def __init__(self) -> None:
self.clients: dict[str, OAuthClientInformationFull] = {}
self.tokens: dict[str, AccessToken] = {}
self.last_params: IdentityAssertionParams | None = None
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
return self.clients.get(client_id)
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
assert client_info.client_id is not None
self.clients[client_info.client_id] = client_info
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
raise NotImplementedError
async def load_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: str
) -> AuthorizationCode | None:
raise NotImplementedError
async def exchange_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
) -> OAuthToken:
raise NotImplementedError
async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None:
raise NotImplementedError
async def exchange_refresh_token(
self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]
) -> OAuthToken:
raise NotImplementedError
async def load_access_token(self, token: str) -> AccessToken | None:
return self.tokens.get(token)
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
raise NotImplementedError
async def exchange_identity_assertion(
self, client: OAuthClientInformationFull, params: IdentityAssertionParams
) -> OAuthToken:
self.last_params = params
# Stand-in for RFC 7523 §3 / SEP-990 §5.1 assertion validation.
if params.assertion != VALID_ASSERTION:
raise TokenError(error="invalid_grant", error_description="assertion is not valid")
assert client.client_id is not None
scopes = params.scopes or ["mcp"]
access = f"access_{secrets.token_hex(16)}"
self.tokens[access] = AccessToken(
token=access,
client_id=client.client_id,
scopes=scopes,
expires_at=int(time.time()) + 3600,
resource=params.resource,
subject="assertion-user",
)
return OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=" ".join(scopes))
@pytest.fixture
def provider() -> IdentityAssertionProvider:
prov = IdentityAssertionProvider()
# Pre-register a confidential client (DCR refuses the grant; see the DCR test).
prov.clients[CONFIDENTIAL_CLIENT_ID] = OAuthClientInformationFull(
client_id=CONFIDENTIAL_CLIENT_ID,
client_secret=CONFIDENTIAL_CLIENT_SECRET,
redirect_uris=None,
grant_types=[JWT_BEARER_GRANT_TYPE],
token_endpoint_auth_method="client_secret_post",
scope="mcp",
)
return prov
@pytest.fixture
def app(provider: IdentityAssertionProvider) -> Starlette:
routes = create_auth_routes(
provider,
issuer_url=AnyHttpUrl("https://auth.example.com"),
client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=["mcp"]),
revocation_options=RevocationOptions(enabled=False),
identity_assertion_enabled=True,
)
return Starlette(routes=routes)
@pytest.fixture
async def client(app: Starlette):
transport = ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="https://auth.example.com") as http:
yield http
def assertion_form(**overrides: str) -> dict[str, str]:
form = {
"grant_type": JWT_BEARER_GRANT_TYPE,
"client_id": CONFIDENTIAL_CLIENT_ID,
"client_secret": CONFIDENTIAL_CLIENT_SECRET,
"assertion": VALID_ASSERTION,
}
form.update(overrides)
return form
def test_build_metadata_advertises_id_jag_profile_when_enabled():
enabled = build_metadata(
AnyHttpUrl("https://auth.example.com"),
None,
ClientRegistrationOptions(),
RevocationOptions(),
supports_identity_assertion=True,
)
assert JWT_BEARER_GRANT_TYPE in (enabled.grant_types_supported or [])
assert enabled.authorization_grant_profiles_supported == [ID_JAG_GRANT_PROFILE]
# The grant is confidential-only, so the `none` auth method is NOT advertised.
assert "none" not in (enabled.token_endpoint_auth_methods_supported or [])
disabled = build_metadata(
AnyHttpUrl("https://auth.example.com"),
None,
ClientRegistrationOptions(),
RevocationOptions(),
)
assert JWT_BEARER_GRANT_TYPE not in (disabled.grant_types_supported or [])
assert disabled.authorization_grant_profiles_supported is None
@pytest.mark.anyio
async def test_metadata_endpoint_lists_id_jag_profile(client: httpx.AsyncClient):
response = await client.get("/.well-known/oauth-authorization-server")
assert response.status_code == 200
body = response.json()
assert JWT_BEARER_GRANT_TYPE in body["grant_types_supported"]
assert body["authorization_grant_profiles_supported"] == [ID_JAG_GRANT_PROFILE]
@pytest.mark.anyio
async def test_identity_assertion_success(client: httpx.AsyncClient, provider: IdentityAssertionProvider):
response = await client.post("/token", data=assertion_form(scope="mcp", resource="https://mcp.example.com/mcp"))
assert response.status_code == 200, response.content
body = response.json()
assert body["token_type"] == "Bearer"
assert "issued_token_type" not in body # plain RFC 6749 response under jwt-bearer
issued = await provider.load_access_token(body["access_token"])
assert issued is not None
assert issued.scopes == ["mcp"]
assert issued.subject == "assertion-user"
assert provider.last_params is not None
assert provider.last_params.assertion == VALID_ASSERTION
assert provider.last_params.scopes == ["mcp"]
assert provider.last_params.resource == "https://mcp.example.com/mcp"
@pytest.mark.anyio
async def test_identity_assertion_invalid_assertion(client: httpx.AsyncClient):
response = await client.post("/token", data=assertion_form(assertion="forged"))
assert response.status_code == 400
assert response.json() == {"error": "invalid_grant", "error_description": "assertion is not valid"}
@pytest.mark.anyio
async def test_identity_assertion_rejected_when_disabled(provider: IdentityAssertionProvider):
routes = create_auth_routes(
provider,
issuer_url=AnyHttpUrl("https://auth.example.com"),
client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=["mcp"]),
revocation_options=RevocationOptions(enabled=False),
identity_assertion_enabled=False,
)
app = Starlette(routes=routes)
transport = ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="https://auth.example.com") as http:
response = await http.post("/token", data=assertion_form())
assert response.status_code == 400
assert response.json()["error"] == "unsupported_grant_type"
assert provider.last_params is None
@pytest.mark.anyio
async def test_identity_assertion_rejects_public_client(client: httpx.AsyncClient, provider: IdentityAssertionProvider):
"""A public (auth method 'none') client cannot use the grant, even if it presents a valid assertion."""
provider.clients["public-client"] = OAuthClientInformationFull(
client_id="public-client",
redirect_uris=None,
grant_types=[JWT_BEARER_GRANT_TYPE],
token_endpoint_auth_method="none",
scope="mcp",
)
response = await client.post(
"/token",
data={"grant_type": JWT_BEARER_GRANT_TYPE, "client_id": "public-client", "assertion": VALID_ASSERTION},
)
assert response.status_code == 400
assert response.json()["error"] == "unauthorized_client"
assert provider.last_params is None
@pytest.mark.anyio
async def test_identity_assertion_rejects_secretless_confidential_client(
client: httpx.AsyncClient, provider: IdentityAssertionProvider
):
"""A client registered with a secret-based method but no stored secret fails authentication.
`ClientAuthenticator` rejects this misconfiguration as `invalid_client`, so the request never
reaches the jwt-bearer handler or the provider hook.
"""
provider.clients["secretless-client"] = OAuthClientInformationFull(
client_id="secretless-client",
client_secret=None,
redirect_uris=None,
grant_types=[JWT_BEARER_GRANT_TYPE],
token_endpoint_auth_method="client_secret_post",
scope="mcp",
)
response = await client.post(
"/token",
data={"grant_type": JWT_BEARER_GRANT_TYPE, "client_id": "secretless-client", "assertion": VALID_ASSERTION},
)
assert response.status_code == 401
body = response.json()
assert body["error"] == "invalid_client"
assert "no stored secret" in body["error_description"]
assert provider.last_params is None
@pytest.mark.anyio
async def test_malformed_request_missing_assertion_is_invalid_request(client: httpx.AsyncClient):
"""A jwt-bearer request without the required `assertion` fails validation with invalid_request."""
response = await client.post(
"/token",
data={
"grant_type": JWT_BEARER_GRANT_TYPE,
"client_id": CONFIDENTIAL_CLIENT_ID,
"client_secret": CONFIDENTIAL_CLIENT_SECRET,
},
)
assert response.status_code == 400
assert response.json()["error"] == "invalid_request"
@pytest.mark.anyio
async def test_client_without_the_grant_registered_is_rejected(
client: httpx.AsyncClient, provider: IdentityAssertionProvider
):
"""A confidential client whose registration omits the jwt-bearer grant is refused the grant."""
provider.clients["no-grant-client"] = OAuthClientInformationFull(
client_id="no-grant-client",
client_secret="s",
redirect_uris=None,
grant_types=["authorization_code"],
token_endpoint_auth_method="client_secret_post",
scope="mcp",
)
response = await client.post(
"/token",
data={
"grant_type": JWT_BEARER_GRANT_TYPE,
"client_id": "no-grant-client",
"client_secret": "s",
"assertion": VALID_ASSERTION,
},
)
assert response.status_code == 400
assert response.json()["error"] == "unsupported_grant_type"
assert provider.last_params is None
@pytest.mark.anyio
async def test_dcr_refuses_to_register_the_jwt_bearer_grant(
client: httpx.AsyncClient, provider: IdentityAssertionProvider
):
"""Dynamic client registration rejects the jwt-bearer grant; the ID-JAG flow needs pre-registration."""
response = await client.post(
"/register",
json={
"redirect_uris": ["https://client.example.com/callback"],
"token_endpoint_auth_method": "client_secret_post",
"grant_types": ["authorization_code", JWT_BEARER_GRANT_TYPE],
"response_types": ["code"],
},
)
assert response.status_code == 400
body = response.json()
assert body["error"] == "invalid_client_metadata"
assert JWT_BEARER_GRANT_TYPE in body["error_description"]
# A registration without the jwt-bearer grant still succeeds and is stored.
ok = await client.post(
"/register",
json={
"redirect_uris": ["https://client.example.com/callback"],
"token_endpoint_auth_method": "client_secret_post",
"grant_types": ["authorization_code"],
"response_types": ["code"],
},
)
assert ok.status_code == 201
assert ok.json()["client_id"] in provider.clients
@pytest.mark.anyio
async def test_default_provider_rejects_identity_assertion():
"""A provider that does not override `exchange_identity_assertion` rejects with unsupported_grant_type."""
class BareProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]):
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
raise NotImplementedError
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
raise NotImplementedError
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
raise NotImplementedError
async def load_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: str
) -> AuthorizationCode | None:
raise NotImplementedError
async def exchange_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
) -> OAuthToken:
raise NotImplementedError
async def load_refresh_token(
self, client: OAuthClientInformationFull, refresh_token: str
) -> RefreshToken | None:
raise NotImplementedError
async def exchange_refresh_token(
self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]
) -> OAuthToken:
raise NotImplementedError
async def load_access_token(self, token: str) -> AccessToken | None:
raise NotImplementedError
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
raise NotImplementedError
bare = BareProvider()
client_info = OAuthClientInformationFull(
redirect_uris=None,
client_id="c",
grant_types=[JWT_BEARER_GRANT_TYPE],
)
params = IdentityAssertionParams(assertion=VALID_ASSERTION)
with pytest.raises(TokenError) as excinfo:
await bare.exchange_identity_assertion(client_info, params)
assert excinfo.value.error == "unsupported_grant_type"
@@ -0,0 +1,198 @@
"""Integration tests for MCP Oauth Protected Resource."""
from urllib.parse import urlparse
import httpx
import pytest
from inline_snapshot import snapshot
from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from mcp.server.auth.routes import build_resource_metadata_url, create_protected_resource_routes
@pytest.fixture
def test_app():
"""Fixture to create protected resource routes for testing."""
# Create the protected resource routes
protected_resource_routes = create_protected_resource_routes(
resource_url=AnyHttpUrl("https://example.com/resource"),
authorization_servers=[AnyHttpUrl("https://auth.example.com/authorization")],
scopes_supported=["read", "write"],
resource_name="Example Resource",
resource_documentation=AnyHttpUrl("https://docs.example.com/resource"),
)
app = Starlette(routes=protected_resource_routes)
return app
@pytest.fixture
async def test_client(test_app: Starlette):
"""Fixture to create an HTTP client for the protected resource app."""
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=test_app), base_url="https://mcptest.com") as client:
yield client
@pytest.mark.anyio
async def test_metadata_endpoint_with_path(test_client: httpx.AsyncClient):
"""Test the OAuth 2.0 Protected Resource metadata endpoint for path-based resource."""
# For resource with path "/resource", metadata should be accessible at the path-aware location
response = await test_client.get("/.well-known/oauth-protected-resource/resource")
assert response.json() == snapshot(
{
"resource": "https://example.com/resource",
"authorization_servers": ["https://auth.example.com/authorization"],
"scopes_supported": ["read", "write"],
"resource_name": "Example Resource",
"resource_documentation": "https://docs.example.com/resource",
"bearer_methods_supported": ["header"],
}
)
@pytest.mark.anyio
async def test_metadata_endpoint_root_path_returns_404(test_client: httpx.AsyncClient):
"""Test that root path returns 404 for path-based resource."""
# Root path should return 404 for path-based resources
response = await test_client.get("/.well-known/oauth-protected-resource")
assert response.status_code == 404
@pytest.fixture
def root_resource_app():
"""Fixture to create protected resource routes for root-level resource."""
# Create routes for a resource without path component
protected_resource_routes = create_protected_resource_routes(
resource_url=AnyHttpUrl("https://example.com"),
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
scopes_supported=["read"],
resource_name="Root Resource",
)
app = Starlette(routes=protected_resource_routes)
return app
@pytest.fixture
async def root_resource_client(root_resource_app: Starlette):
"""Fixture to create an HTTP client for the root resource app."""
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=root_resource_app), base_url="https://mcptest.com"
) as client:
yield client
@pytest.mark.anyio
async def test_metadata_endpoint_without_path(root_resource_client: httpx.AsyncClient):
"""Test metadata endpoint for root-level resource."""
# For root resource, metadata should be at standard location
response = await root_resource_client.get("/.well-known/oauth-protected-resource")
assert response.status_code == 200
assert response.json() == snapshot(
{
"resource": "https://example.com/",
"authorization_servers": ["https://auth.example.com/"],
"scopes_supported": ["read"],
"resource_name": "Root Resource",
"bearer_methods_supported": ["header"],
}
)
# Tests for URL construction utility function
def test_metadata_url_construction_url_without_path():
"""Test URL construction for resource without path component."""
resource_url = AnyHttpUrl("https://example.com")
result = build_resource_metadata_url(resource_url)
assert str(result) == "https://example.com/.well-known/oauth-protected-resource"
def test_metadata_url_construction_url_with_path_component():
"""Test URL construction for resource with path component."""
resource_url = AnyHttpUrl("https://example.com/mcp")
result = build_resource_metadata_url(resource_url)
assert str(result) == "https://example.com/.well-known/oauth-protected-resource/mcp"
def test_metadata_url_construction_url_with_trailing_slash_only():
"""Test URL construction for resource with trailing slash only."""
resource_url = AnyHttpUrl("https://example.com/")
result = build_resource_metadata_url(resource_url)
# Trailing slash should be treated as empty path
assert str(result) == "https://example.com/.well-known/oauth-protected-resource"
@pytest.mark.parametrize(
"resource_url,expected_url",
[
("https://example.com", "https://example.com/.well-known/oauth-protected-resource"),
("https://example.com/", "https://example.com/.well-known/oauth-protected-resource"),
("https://example.com/mcp", "https://example.com/.well-known/oauth-protected-resource/mcp"),
("http://localhost:8001/mcp", "http://localhost:8001/.well-known/oauth-protected-resource/mcp"),
],
)
def test_metadata_url_construction_various_resource_configurations(resource_url: str, expected_url: str):
"""Test URL construction with various resource configurations."""
result = build_resource_metadata_url(AnyHttpUrl(resource_url))
assert str(result) == expected_url
# Tests for consistency between URL generation and route registration
def test_route_consistency_route_path_matches_metadata_url():
"""Test that route path matches the generated metadata URL."""
resource_url = AnyHttpUrl("https://example.com/mcp")
# Generate metadata URL
metadata_url = build_resource_metadata_url(resource_url)
# Create routes
routes = create_protected_resource_routes(
resource_url=resource_url,
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
)
# Extract path from metadata URL
metadata_path = urlparse(str(metadata_url)).path
# Verify consistency
assert len(routes) == 1
assert routes[0].path == metadata_path
@pytest.mark.parametrize(
"resource_url,expected_path",
[
("https://example.com", "/.well-known/oauth-protected-resource"),
("https://example.com/", "/.well-known/oauth-protected-resource"),
("https://example.com/mcp", "/.well-known/oauth-protected-resource/mcp"),
],
)
def test_route_consistency_consistent_paths_for_various_resources(resource_url: str, expected_path: str):
"""Test that URL generation and route creation are consistent."""
resource_url_obj = AnyHttpUrl(resource_url)
# Test URL generation
metadata_url = build_resource_metadata_url(resource_url_obj)
url_path = urlparse(str(metadata_url)).path
# Test route creation
routes = create_protected_resource_routes(
resource_url=resource_url_obj,
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
)
route_path = routes[0].path
# Both should match expected path
assert url_path == expected_path
assert route_path == expected_path
assert url_path == route_path
+90
View File
@@ -0,0 +1,90 @@
"""Tests for mcp.server.auth.provider module."""
from mcp.server.auth.provider import AccessToken, construct_redirect_uri, principal_components
def test_construct_redirect_uri_no_existing_params():
"""Test construct_redirect_uri with no existing query parameters."""
base_uri = "http://localhost:8000/callback"
result = construct_redirect_uri(base_uri, code="auth_code", state="test_state")
assert "http://localhost:8000/callback?code=auth_code&state=test_state" == result
def test_construct_redirect_uri_with_existing_params():
"""Test construct_redirect_uri with existing query parameters (regression test for #1279)."""
base_uri = "http://localhost:8000/callback?session_id=1234"
result = construct_redirect_uri(base_uri, code="auth_code", state="test_state")
# Should preserve existing params and add new ones
assert "session_id=1234" in result
assert "code=auth_code" in result
assert "state=test_state" in result
assert result.startswith("http://localhost:8000/callback?")
def test_construct_redirect_uri_multiple_existing_params():
"""Test construct_redirect_uri with multiple existing query parameters."""
base_uri = "http://localhost:8000/callback?session_id=1234&user=test"
result = construct_redirect_uri(base_uri, code="auth_code")
assert "session_id=1234" in result
assert "user=test" in result
assert "code=auth_code" in result
def test_construct_redirect_uri_with_none_values():
"""Test construct_redirect_uri filters out None values."""
base_uri = "http://localhost:8000/callback"
result = construct_redirect_uri(base_uri, code="auth_code", state=None)
assert result == "http://localhost:8000/callback?code=auth_code"
assert "state" not in result
def test_construct_redirect_uri_empty_params():
"""Test construct_redirect_uri with no additional parameters."""
base_uri = "http://localhost:8000/callback?existing=param"
result = construct_redirect_uri(base_uri)
assert result == "http://localhost:8000/callback?existing=param"
def test_construct_redirect_uri_duplicate_param_names():
"""Test construct_redirect_uri when adding param that already exists."""
base_uri = "http://localhost:8000/callback?code=existing"
result = construct_redirect_uri(base_uri, code="new_code")
# Should contain both values (this is expected behavior of parse_qs/urlencode)
assert "code=existing" in result
assert "code=new_code" in result
def test_construct_redirect_uri_multivalued_existing_params():
"""Test construct_redirect_uri with existing multi-valued parameters."""
base_uri = "http://localhost:8000/callback?scope=read&scope=write"
result = construct_redirect_uri(base_uri, code="auth_code")
assert "scope=read" in result
assert "scope=write" in result
assert "code=auth_code" in result
def test_construct_redirect_uri_encoded_values():
"""Test construct_redirect_uri handles URL encoding properly."""
base_uri = "http://localhost:8000/callback"
result = construct_redirect_uri(base_uri, state="test state with spaces")
# urlencode uses + for spaces by default
assert "state=test+state+with+spaces" in result
def test_principal_components_composes_client_issuer_subject():
"""The triple identifying a token's principal, degrading per missing component."""
bare = AccessToken(token="t", client_id="client-1", scopes=[])
assert principal_components(bare) == ("client-1", None, None)
full = AccessToken(
token="t", client_id="client-1", scopes=[], subject="alice", claims={"iss": "https://as.example"}
)
assert principal_components(full) == ("client-1", "https://as.example", "alice")
+72
View File
@@ -0,0 +1,72 @@
import pytest
from pydantic import AnyHttpUrl
from mcp.server.auth.routes import build_metadata, validate_issuer_url
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions
def test_validate_issuer_url_https_allowed():
validate_issuer_url(AnyHttpUrl("https://example.com/path"))
def test_validate_issuer_url_http_localhost_allowed():
validate_issuer_url(AnyHttpUrl("http://localhost:8080/path"))
def test_validate_issuer_url_http_127_0_0_1_allowed():
validate_issuer_url(AnyHttpUrl("http://127.0.0.1:8080/path"))
def test_validate_issuer_url_http_ipv6_loopback_allowed():
validate_issuer_url(AnyHttpUrl("http://[::1]:8080/path"))
def test_validate_issuer_url_http_non_loopback_rejected():
with pytest.raises(ValueError, match="Issuer URL must be HTTPS"):
validate_issuer_url(AnyHttpUrl("http://evil.com/path"))
def test_validate_issuer_url_http_127_prefix_domain_rejected():
"""A domain like 127.0.0.1.evil.com is not loopback."""
with pytest.raises(ValueError, match="Issuer URL must be HTTPS"):
validate_issuer_url(AnyHttpUrl("http://127.0.0.1.evil.com/path"))
def test_validate_issuer_url_http_127_prefix_subdomain_rejected():
"""A domain like 127.0.0.1something.example.com is not loopback."""
with pytest.raises(ValueError, match="Issuer URL must be HTTPS"):
validate_issuer_url(AnyHttpUrl("http://127.0.0.1something.example.com/path"))
def test_validate_issuer_url_fragment_rejected():
with pytest.raises(ValueError, match="fragment"):
validate_issuer_url(AnyHttpUrl("https://example.com/path#frag"))
def test_validate_issuer_url_query_rejected():
with pytest.raises(ValueError, match="query"):
validate_issuer_url(AnyHttpUrl("https://example.com/path?q=1"))
def test_auth_settings_preserves_path_less_issuer():
"""A path-less issuer passed as a string keeps its canonical form (no trailing slash)."""
settings = AuthSettings(
issuer_url="https://as.example.com", # type: ignore[arg-type]
resource_server_url="https://rs.example.com", # type: ignore[arg-type]
)
assert str(settings.issuer_url) == "https://as.example.com"
assert str(settings.resource_server_url) == "https://rs.example.com"
def test_build_metadata_serves_issuer_without_trailing_slash():
"""The served issuer matches the configured one exactly (RFC 8414/9207 string comparison)."""
settings = AuthSettings(
issuer_url="https://as.example.com", # type: ignore[arg-type]
resource_server_url="https://rs.example.com", # type: ignore[arg-type]
)
metadata = build_metadata(settings.issuer_url, None, ClientRegistrationOptions(), RevocationOptions())
served = metadata.model_dump(mode="json", exclude_none=True)
assert served["issuer"] == "https://as.example.com"
assert served["authorization_endpoint"] == "https://as.example.com/authorize"
assert served["token_endpoint"] == "https://as.example.com/token"
+46
View File
@@ -0,0 +1,46 @@
"""Shared fixtures for server-side tests."""
from collections.abc import Iterator
import pytest
from logfire.testing import CaptureLogfire, TestExporter
from opentelemetry.sdk.trace import ReadableSpan
class SpanCapture:
"""Thin adapter over logfire's `TestExporter` for asserting on MCP spans.
`finished()` returns the raw `ReadableSpan` objects emitted by the
`mcp-python-sdk` instrumentation scope, filtered to exclude logfire's
synthetic `pending_span` markers, so tests can assert directly on
`.name`, `.kind`, `.status`, `.attributes`, `.parent`, `.events`.
"""
def __init__(self, exporter: TestExporter) -> None:
self._exporter = exporter
def clear(self) -> None:
self._exporter.clear()
def finished(self) -> list[ReadableSpan]:
return [
s
for s in self._exporter.exported_spans
if s.instrumentation_scope is not None
and s.instrumentation_scope.name == "mcp-python-sdk"
and not (s.attributes and s.attributes.get("logfire.span_type") == "pending_span")
]
@pytest.fixture
def spans(capfire: CaptureLogfire) -> Iterator[SpanCapture]:
"""In-memory MCP span capture, cleared before and after each test.
Backed by the project-level `capfire` override (see `tests/conftest.py`),
which scopes `mcp.shared._otel._tracer` to the test so the real tracer
doesn't leak into later tests in the same worker.
"""
capture = SpanCapture(capfire.exporter)
capture.clear()
yield capture
capture.clear()
View File
@@ -0,0 +1,59 @@
"""Test helper_types.py meta field.
These tests verify the changes made to helper_types.py:11 where we added:
meta: dict[str, Any] | None = field(default=None)
ReadResourceContents is the return type for resource read handlers. It's used internally
by the low-level server to package resource content before sending it over the MCP protocol.
"""
from mcp.server.lowlevel.helper_types import ReadResourceContents
def test_read_resource_contents_with_metadata():
"""Test that ReadResourceContents accepts meta parameter.
ReadResourceContents is an internal helper type used by the low-level MCP server.
When a resource is read, the server creates a ReadResourceContents instance that
contains the content, mime type, and now metadata. The low-level server then
extracts the meta field and includes it in the protocol response as _meta.
"""
# Bridge between Resource.meta and MCP protocol _meta field (helper_types.py:11)
metadata = {"version": "1.0", "cached": True}
contents = ReadResourceContents(
content="test content",
mime_type="text/plain",
meta=metadata,
)
assert contents.meta is not None
assert contents.meta == metadata
assert contents.meta["version"] == "1.0"
assert contents.meta["cached"] is True
def test_read_resource_contents_without_metadata():
"""Test that ReadResourceContents meta defaults to None."""
# Ensures backward compatibility - meta defaults to None, _meta omitted from protocol (helper_types.py:11)
contents = ReadResourceContents(
content="test content",
mime_type="text/plain",
)
assert contents.meta is None
def test_read_resource_contents_with_bytes():
"""Test that ReadResourceContents works with bytes content and meta."""
# Verifies meta works with both str and bytes content (binary resources like images, PDFs)
metadata = {"encoding": "utf-8"}
contents = ReadResourceContents(
content=b"binary content",
mime_type="application/octet-stream",
meta=metadata,
)
assert contents.content == b"binary content"
assert contents.meta == metadata
@@ -0,0 +1,220 @@
"""Direct-handler tests for the auto-derived `server/discover` handler.
These call the registered handler via the public `Server.get_request_handler`
accessor without spinning up a `ServerRunner` or any transport, so they verify
the handler's contract in isolation from the dispatch pipeline.
"""
import importlib.metadata
from typing import Any, cast
import mcp_types as types
import pytest
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from mcp.server import NotificationOptions, Server, ServerRequestContext
# `Server._handle_discover` reads only `ctx.protocol_version` (capabilities are
# era-dependent), so a minimal context keeps the call site honest without
# dragging session machinery into a unit test.
def _ctx(protocol_version: str) -> ServerRequestContext[Any]:
return ServerRequestContext(
session=cast("Any", None),
lifespan_context={},
protocol_version=protocol_version,
method="server/discover",
request_id=1,
)
async def _discover(server: Server[Any], protocol_version: str = MODERN_PROTOCOL_VERSIONS[0]) -> types.DiscoverResult:
entry = server.get_request_handler("server/discover")
assert entry is not None
result = await entry.handler(_ctx(protocol_version), types.RequestParams())
assert isinstance(result, types.DiscoverResult)
return result
def test_registered_by_default() -> None:
"""SDK-defined: a bare `Server` registers a `server/discover` handler out of
the box, typed for the base `RequestParams`."""
server = Server("test-server")
entry = server.get_request_handler("server/discover")
assert entry is not None
assert entry.params_type is types.RequestParams
@pytest.mark.anyio
async def test_supported_versions_is_modern_set() -> None:
"""`supportedVersions` is exactly the modern envelope set, not the full
legacy-compat list (D-008)."""
result = await _discover(Server("test-server"))
assert result.supported_versions == list(MODERN_PROTOCOL_VERSIONS)
@pytest.mark.anyio
async def test_server_info_reflects_constructor_fields() -> None:
"""SDK-defined: `serverInfo` is built field-for-field from the `Server`
constructor arguments."""
icons = [types.Icon(src="https://example.test/icon.png")]
server = Server(
"info-server",
version="9.9.9",
title="Info Server",
description="A server for testing discover.",
website_url="https://example.test",
icons=icons,
)
result = await _discover(server)
assert result.server_info == types.Implementation(
name="info-server",
version="9.9.9",
title="Info Server",
description="A server for testing discover.",
website_url="https://example.test",
icons=icons,
)
@pytest.mark.anyio
async def test_server_info_version_falls_back_to_package() -> None:
"""SDK-defined: when no explicit version is supplied, `serverInfo.version`
falls back to the installed `mcp` package version."""
result = await _discover(Server("unversioned"))
assert result.server_info.version == importlib.metadata.version("mcp")
@pytest.mark.anyio
async def test_instructions_threaded_through() -> None:
"""SDK-defined: the `instructions` constructor argument is passed through
verbatim, defaulting to `None` when omitted."""
server = Server("inst-server", instructions="Read the docs first.")
result = await _discover(server)
assert result.instructions == "Read the docs first."
bare = await _discover(Server("bare"))
assert bare.instructions is None
@pytest.mark.anyio
async def test_capabilities_derived_from_registered_handlers() -> None:
"""SDK-defined: capabilities are computed at handler call time from the
live registry, so post-construction `add_request_handler` calls are
reflected."""
async def list_tools(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
raise NotImplementedError
async def list_prompts(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListPromptsResult:
raise NotImplementedError
server = Server("cap-server", on_list_tools=list_tools)
before = await _discover(server)
assert before.capabilities.tools is not None
assert before.capabilities.prompts is None
server.add_request_handler("prompts/list", types.PaginatedRequestParams, list_prompts)
after = await _discover(server)
assert after.capabilities.tools is not None
assert after.capabilities.prompts is not None
@pytest.mark.anyio
async def test_discover_result_defaults_to_immediately_stale_private_cache() -> None:
"""SDK-defined: `DiscoverResult` is cacheable; the auto-derived handler
relies on the model defaults (immediately-stale, private)."""
result = await _discover(Server("cache-server"))
assert result.ttl_ms == 0
assert result.cache_scope == "private"
@pytest.mark.anyio
async def test_overridable_via_add_request_handler() -> None:
"""SDK-defined: a custom `server/discover` handler registered via
`add_request_handler` replaces the auto-derived default wholesale."""
server = Server("custom-server", version="1.0.0")
custom = types.DiscoverResult(
supported_versions=list(MODERN_PROTOCOL_VERSIONS),
capabilities=types.ServerCapabilities(),
server_info=types.Implementation(name="custom-server", version="1.0.0"),
instructions="overridden",
ttl_ms=60_000,
cache_scope="public",
)
async def custom_discover(
ctx: ServerRequestContext[Any], params: types.RequestParams | None
) -> types.DiscoverResult:
return custom
server.add_request_handler("server/discover", types.RequestParams, custom_discover)
result = await _discover(server)
assert result is custom
async def _listen_stub(
ctx: ServerRequestContext[Any], params: types.SubscriptionsListenRequestParams
) -> types.SubscriptionsListenResult:
raise NotImplementedError
@pytest.mark.anyio
async def test_modern_subscription_bits_derive_from_listen_serving() -> None:
"""Spec-driven (SEP-2575): at 2026-07-28, change notifications exist only on
`subscriptions/listen` streams, so the `listChanged`/`subscribe` bits mean
"this server serves listen" - they flip together with the handler."""
async def list_tools(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
raise NotImplementedError
async def list_resources(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListResourcesResult:
raise NotImplementedError
server = Server("caps", on_list_tools=list_tools, on_list_resources=list_resources)
before = await _discover(server)
assert before.capabilities.tools is not None and before.capabilities.tools.list_changed is False
assert before.capabilities.resources is not None
assert before.capabilities.resources.subscribe is False
assert before.capabilities.resources.list_changed is False
server.add_request_handler("subscriptions/listen", types.SubscriptionsListenRequestParams, _listen_stub)
after = await _discover(server)
assert after.capabilities.tools is not None and after.capabilities.tools.list_changed is True
assert after.capabilities.resources is not None
assert after.capabilities.resources.subscribe is True
assert after.capabilities.resources.list_changed is True
@pytest.mark.anyio
async def test_legacy_capability_derivation_ignores_listen() -> None:
"""SDK-defined: without `protocol_version`, `get_capabilities` keeps the
handshake-era derivation - `NotificationOptions` drives `listChanged` and the
`resources/subscribe` handler drives `subscribe`; a registered listen handler
changes nothing on that path."""
async def list_tools(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
raise NotImplementedError
server = Server("caps", on_list_tools=list_tools)
server.add_request_handler("subscriptions/listen", types.SubscriptionsListenRequestParams, _listen_stub)
legacy = server.get_capabilities()
assert legacy.tools is not None and legacy.tools.list_changed is False
opted_in = server.get_capabilities(NotificationOptions(tools_changed=True))
assert opted_in.tools is not None and opted_in.tools.list_changed is True
@@ -0,0 +1,134 @@
"""Basic tests for list_prompts, list_resources, and list_tools handlers without pagination."""
import pytest
from mcp_types import (
ListPromptsResult,
ListResourcesResult,
ListToolsResult,
PaginatedRequestParams,
Prompt,
Resource,
Tool,
)
from mcp import Client
from mcp.server import Server, ServerRequestContext
@pytest.mark.anyio
async def test_list_prompts_basic() -> None:
"""Test basic prompt listing without pagination."""
test_prompts = [
Prompt(name="prompt1", description="First prompt"),
Prompt(name="prompt2", description="Second prompt"),
]
async def handle_list_prompts(
ctx: ServerRequestContext, params: PaginatedRequestParams | None
) -> ListPromptsResult:
return ListPromptsResult(prompts=test_prompts)
server = Server("test", on_list_prompts=handle_list_prompts)
async with Client(server) as client:
result = await client.list_prompts()
assert result.prompts == test_prompts
@pytest.mark.anyio
async def test_list_resources_basic() -> None:
"""Test basic resource listing without pagination."""
test_resources = [
Resource(uri="file:///test1.txt", name="Test 1"),
Resource(uri="file:///test2.txt", name="Test 2"),
]
async def handle_list_resources(
ctx: ServerRequestContext, params: PaginatedRequestParams | None
) -> ListResourcesResult:
return ListResourcesResult(resources=test_resources)
server = Server("test", on_list_resources=handle_list_resources)
async with Client(server) as client:
result = await client.list_resources()
assert result.resources == test_resources
@pytest.mark.anyio
async def test_list_tools_basic() -> None:
"""Test basic tool listing without pagination."""
test_tools = [
Tool(
name="tool1",
description="First tool",
input_schema={
"type": "object",
"properties": {
"message": {"type": "string"},
},
"required": ["message"],
},
),
Tool(
name="tool2",
description="Second tool",
input_schema={
"type": "object",
"properties": {
"count": {"type": "number"},
"enabled": {"type": "boolean"},
},
"required": ["count"],
},
),
]
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=test_tools)
server = Server("test", on_list_tools=handle_list_tools)
async with Client(server) as client:
result = await client.list_tools()
assert result.tools == test_tools
@pytest.mark.anyio
async def test_list_prompts_empty() -> None:
"""Test listing with empty results."""
async def handle_list_prompts(
ctx: ServerRequestContext, params: PaginatedRequestParams | None
) -> ListPromptsResult:
return ListPromptsResult(prompts=[])
server = Server("test", on_list_prompts=handle_list_prompts)
async with Client(server) as client:
result = await client.list_prompts()
assert result.prompts == []
@pytest.mark.anyio
async def test_list_resources_empty() -> None:
"""Test listing with empty results."""
async def handle_list_resources(
ctx: ServerRequestContext, params: PaginatedRequestParams | None
) -> ListResourcesResult:
return ListResourcesResult(resources=[])
server = Server("test", on_list_resources=handle_list_resources)
async with Client(server) as client:
result = await client.list_resources()
assert result.resources == []
@pytest.mark.anyio
async def test_list_tools_empty() -> None:
"""Test listing with empty results."""
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[])
server = Server("test", on_list_tools=handle_list_tools)
async with Client(server) as client:
result = await client.list_tools()
assert result.tools == []
@@ -0,0 +1,83 @@
import pytest
from mcp_types import (
ListPromptsResult,
ListResourcesResult,
ListToolsResult,
PaginatedRequestParams,
)
from mcp import Client
from mcp.server import Server, ServerRequestContext
@pytest.mark.anyio
async def test_list_prompts_pagination() -> None:
test_cursor = "test-cursor-123"
received_params: PaginatedRequestParams | None = None
async def handle_list_prompts(
ctx: ServerRequestContext, params: PaginatedRequestParams | None
) -> ListPromptsResult:
nonlocal received_params
received_params = params
return ListPromptsResult(prompts=[], next_cursor="next")
server = Server("test", on_list_prompts=handle_list_prompts)
async with Client(server) as client:
# No cursor provided
await client.list_prompts()
assert received_params is not None
assert received_params.cursor is None
# Cursor provided
await client.list_prompts(cursor=test_cursor)
assert received_params is not None
assert received_params.cursor == test_cursor
@pytest.mark.anyio
async def test_list_resources_pagination() -> None:
test_cursor = "resource-cursor-456"
received_params: PaginatedRequestParams | None = None
async def handle_list_resources(
ctx: ServerRequestContext, params: PaginatedRequestParams | None
) -> ListResourcesResult:
nonlocal received_params
received_params = params
return ListResourcesResult(resources=[], next_cursor="next")
server = Server("test", on_list_resources=handle_list_resources)
async with Client(server) as client:
# No cursor provided
await client.list_resources()
assert received_params is not None
assert received_params.cursor is None
# Cursor provided
await client.list_resources(cursor=test_cursor)
assert received_params is not None
assert received_params.cursor == test_cursor
@pytest.mark.anyio
async def test_list_tools_pagination() -> None:
test_cursor = "tools-cursor-789"
received_params: PaginatedRequestParams | None = None
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
nonlocal received_params
received_params = params
return ListToolsResult(tools=[], next_cursor="next")
server = Server("test", on_list_tools=handle_list_tools)
async with Client(server) as client:
# No cursor provided
await client.list_tools()
assert received_params is not None
assert received_params.cursor is None
# Cursor provided
await client.list_tools(cursor=test_cursor)
assert received_params is not None
assert received_params.cursor == test_cursor
View File
+1
View File
@@ -0,0 +1 @@
"""Tests for the MCP server auth components."""
File diff suppressed because it is too large Load Diff
+245
View File
@@ -0,0 +1,245 @@
import threading
from typing import Any
import pytest
from mcp_types import (
ElicitRequest,
ElicitRequestFormParams,
EmbeddedResource,
InputRequiredResult,
TextContent,
TextResourceContents,
)
from mcp.server.mcpserver import Context
from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, Prompt, UserMessage
class TestRenderPrompt:
@pytest.mark.anyio
async def test_basic_fn(self):
def fn() -> str:
return "Hello, world!"
prompt = Prompt.from_function(fn)
assert await prompt.render(None, Context()) == [
UserMessage(content=TextContent(type="text", text="Hello, world!"))
]
@pytest.mark.anyio
async def test_async_fn(self):
async def fn() -> str:
return "Hello, world!"
prompt = Prompt.from_function(fn)
assert await prompt.render(None, Context()) == [
UserMessage(content=TextContent(type="text", text="Hello, world!"))
]
@pytest.mark.anyio
async def test_fn_with_args(self):
async def fn(name: str, age: int = 30) -> str:
return f"Hello, {name}! You're {age} years old."
prompt = Prompt.from_function(fn)
assert await prompt.render({"name": "World"}, Context()) == [
UserMessage(content=TextContent(type="text", text="Hello, World! You're 30 years old."))
]
@pytest.mark.anyio
async def test_fn_with_invalid_kwargs(self):
async def fn(name: str, age: int = 30) -> str: # pragma: no cover
return f"Hello, {name}! You're {age} years old."
prompt = Prompt.from_function(fn)
with pytest.raises(ValueError):
await prompt.render({"age": 40}, Context())
@pytest.mark.anyio
async def test_fn_returns_message(self):
async def fn() -> UserMessage:
return UserMessage(content="Hello, world!")
prompt = Prompt.from_function(fn)
assert await prompt.render(None, Context()) == [
UserMessage(content=TextContent(type="text", text="Hello, world!"))
]
@pytest.mark.anyio
async def test_fn_returns_assistant_message(self):
async def fn() -> AssistantMessage:
return AssistantMessage(content=TextContent(type="text", text="Hello, world!"))
prompt = Prompt.from_function(fn)
assert await prompt.render(None, Context()) == [
AssistantMessage(content=TextContent(type="text", text="Hello, world!"))
]
@pytest.mark.anyio
async def test_fn_returns_multiple_messages(self):
expected: list[Message] = [
UserMessage("Hello, world!"),
AssistantMessage("How can I help you today?"),
UserMessage("I'm looking for a restaurant in the center of town."),
]
async def fn() -> list[Message]:
return expected
prompt = Prompt.from_function(fn)
assert await prompt.render(None, Context()) == expected
@pytest.mark.anyio
async def test_fn_returns_list_of_strings(self):
expected = [
"Hello, world!",
"I'm looking for a restaurant in the center of town.",
]
async def fn() -> list[str]:
return expected
prompt = Prompt.from_function(fn)
assert await prompt.render(None, Context()) == [UserMessage(t) for t in expected]
@pytest.mark.anyio
async def test_fn_returns_resource_content(self):
"""Test returning a message with resource content."""
async def fn() -> UserMessage:
return UserMessage(
content=EmbeddedResource(
type="resource",
resource=TextResourceContents(
uri="file://file.txt",
text="File contents",
mime_type="text/plain",
),
)
)
prompt = Prompt.from_function(fn)
assert await prompt.render(None, Context()) == [
UserMessage(
content=EmbeddedResource(
type="resource",
resource=TextResourceContents(
uri="file://file.txt",
text="File contents",
mime_type="text/plain",
),
)
)
]
@pytest.mark.anyio
async def test_fn_returns_mixed_content(self):
"""Test returning messages with mixed content types."""
async def fn() -> list[Message]:
return [
UserMessage(content="Please analyze this file:"),
UserMessage(
content=EmbeddedResource(
type="resource",
resource=TextResourceContents(
uri="file://file.txt",
text="File contents",
mime_type="text/plain",
),
)
),
AssistantMessage(content="I'll help analyze that file."),
]
prompt = Prompt.from_function(fn)
assert await prompt.render(None, Context()) == [
UserMessage(content=TextContent(type="text", text="Please analyze this file:")),
UserMessage(
content=EmbeddedResource(
type="resource",
resource=TextResourceContents(
uri="file://file.txt",
text="File contents",
mime_type="text/plain",
),
)
),
AssistantMessage(content=TextContent(type="text", text="I'll help analyze that file.")),
]
@pytest.mark.anyio
async def test_fn_returns_dict_with_resource(self):
"""Test returning a dict with resource content."""
async def fn() -> dict[str, Any]:
return {
"role": "user",
"content": {
"type": "resource",
"resource": {
"uri": "file://file.txt",
"text": "File contents",
"mimeType": "text/plain",
},
},
}
prompt = Prompt.from_function(fn)
assert await prompt.render(None, Context()) == [
UserMessage(
content=EmbeddedResource(
type="resource",
resource=TextResourceContents(
uri="file://file.txt",
text="File contents",
mime_type="text/plain",
),
)
)
]
@pytest.mark.anyio
async def test_sync_fn_runs_in_worker_thread():
"""Sync prompt functions must run in a worker thread, not the event loop."""
main_thread = threading.get_ident()
fn_thread: list[int] = []
def blocking_fn() -> str:
fn_thread.append(threading.get_ident())
return "hello"
prompt = Prompt.from_function(blocking_fn)
messages = await prompt.render(None, Context())
assert messages == [UserMessage(content=TextContent(type="text", text="hello"))]
assert fn_thread[0] != main_thread
@pytest.mark.anyio
async def test_render_passes_input_required_result_through_unchanged():
"""Prompt.render returns the InputRequiredResult the function returned, bypassing
message conversion entirely (SEP-2322 multi-round-trip pass-through)."""
sentinel = InputRequiredResult(
input_requests={
"who": ElicitRequest(
params=ElicitRequestFormParams(
message="Who is this for?",
requested_schema={
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
)
)
}
)
def asking_prompt() -> InputRequiredResult:
return sentinel
prompt = Prompt.from_function(asking_prompt)
result = await prompt.render(None, Context())
assert result is sentinel
@@ -0,0 +1,110 @@
import pytest
from mcp_types import TextContent
from mcp.server.mcpserver import Context
from mcp.server.mcpserver.prompts.base import Prompt, UserMessage
from mcp.server.mcpserver.prompts.manager import PromptManager
class TestPromptManager:
def test_add_prompt(self):
"""Test adding a prompt to the manager."""
def fn() -> str: # pragma: no cover
return "Hello, world!"
manager = PromptManager()
prompt = Prompt.from_function(fn)
added = manager.add_prompt(prompt)
assert added == prompt
assert manager.get_prompt("fn") == prompt
def test_add_duplicate_prompt(self, caplog: pytest.LogCaptureFixture):
"""Test adding the same prompt twice."""
def fn() -> str: # pragma: no cover
return "Hello, world!"
manager = PromptManager()
prompt = Prompt.from_function(fn)
first = manager.add_prompt(prompt)
second = manager.add_prompt(prompt)
assert first == second
assert "Prompt already exists" in caplog.text
def test_disable_warn_on_duplicate_prompts(self, caplog: pytest.LogCaptureFixture):
"""Test disabling warning on duplicate prompts."""
def fn() -> str: # pragma: no cover
return "Hello, world!"
manager = PromptManager(warn_on_duplicate_prompts=False)
prompt = Prompt.from_function(fn)
first = manager.add_prompt(prompt)
second = manager.add_prompt(prompt)
assert first == second
assert "Prompt already exists" not in caplog.text
def test_list_prompts(self):
"""Test listing all prompts."""
def fn1() -> str: # pragma: no cover
return "Hello, world!"
def fn2() -> str: # pragma: no cover
return "Goodbye, world!"
manager = PromptManager()
prompt1 = Prompt.from_function(fn1)
prompt2 = Prompt.from_function(fn2)
manager.add_prompt(prompt1)
manager.add_prompt(prompt2)
prompts = manager.list_prompts()
assert len(prompts) == 2
assert prompts == [prompt1, prompt2]
@pytest.mark.anyio
async def test_render_prompt(self):
"""Test rendering a prompt."""
def fn() -> str:
return "Hello, world!"
manager = PromptManager()
prompt = Prompt.from_function(fn)
manager.add_prompt(prompt)
messages = await manager.render_prompt("fn", None, Context())
assert messages == [UserMessage(content=TextContent(type="text", text="Hello, world!"))]
@pytest.mark.anyio
async def test_render_prompt_with_args(self):
"""Test rendering a prompt with arguments."""
def fn(name: str) -> str:
return f"Hello, {name}!"
manager = PromptManager()
prompt = Prompt.from_function(fn)
manager.add_prompt(prompt)
messages = await manager.render_prompt("fn", {"name": "World"}, Context())
assert messages == [UserMessage(content=TextContent(type="text", text="Hello, World!"))]
@pytest.mark.anyio
async def test_render_unknown_prompt(self):
"""Test rendering a non-existent prompt."""
manager = PromptManager()
with pytest.raises(ValueError, match="Unknown prompt: unknown"):
await manager.render_prompt("unknown", None, Context())
@pytest.mark.anyio
async def test_render_prompt_with_missing_args(self):
"""Test rendering a prompt with missing required arguments."""
def fn(name: str) -> str: # pragma: no cover
return f"Hello, {name}!"
manager = PromptManager()
prompt = Prompt.from_function(fn)
manager.add_prompt(prompt)
with pytest.raises(ValueError, match="Missing required arguments"):
await manager.render_prompt("fn", None, Context())
@@ -0,0 +1,116 @@
import os
from pathlib import Path
from tempfile import NamedTemporaryFile
import pytest
from mcp.server.mcpserver.resources import FileResource
@pytest.fixture
def temp_file():
"""Create a temporary file for testing.
File is automatically cleaned up after the test if it still exists.
"""
content = "test content"
with NamedTemporaryFile(mode="w", delete=False) as f:
f.write(content)
path = Path(f.name).resolve()
yield path
try: # pragma: lax no cover
path.unlink()
except FileNotFoundError: # pragma: lax no cover
pass # File was already deleted by the test
class TestFileResource:
"""Test FileResource functionality."""
def test_file_resource_creation(self, temp_file: Path):
"""Test creating a FileResource."""
resource = FileResource(
uri=temp_file.as_uri(),
name="test",
description="test file",
path=temp_file,
)
assert str(resource.uri) == temp_file.as_uri()
assert resource.name == "test"
assert resource.description == "test file"
assert resource.mime_type == "text/plain" # default
assert resource.path == temp_file
assert resource.is_binary is False # default
def test_file_resource_str_path_conversion(self, temp_file: Path):
"""Test FileResource handles string paths."""
resource = FileResource(
uri=f"file://{temp_file}",
name="test",
path=Path(str(temp_file)),
)
assert isinstance(resource.path, Path)
assert resource.path.is_absolute()
@pytest.mark.anyio
async def test_read_text_file(self, temp_file: Path):
"""Test reading a text file."""
resource = FileResource(
uri=f"file://{temp_file}",
name="test",
path=temp_file,
)
content = await resource.read()
assert content == "test content"
assert resource.mime_type == "text/plain"
@pytest.mark.anyio
async def test_read_binary_file(self, temp_file: Path):
"""Test reading a file as binary."""
resource = FileResource(
uri=f"file://{temp_file}",
name="test",
path=temp_file,
is_binary=True,
)
content = await resource.read()
assert isinstance(content, bytes)
assert content == b"test content"
def test_relative_path_error(self):
"""Test error on relative path."""
with pytest.raises(ValueError, match="Path must be absolute"):
FileResource(
uri="file:///test.txt",
name="test",
path=Path("test.txt"),
)
@pytest.mark.anyio
async def test_missing_file_error(self, temp_file: Path):
"""Test error when file doesn't exist."""
# Create path to non-existent file
missing = temp_file.parent / "missing.txt"
resource = FileResource(
uri="file:///missing.txt",
name="test",
path=missing,
)
with pytest.raises(ValueError, match="Error reading file"):
await resource.read()
@pytest.mark.skipif(os.name == "nt", reason="File permissions behave differently on Windows")
@pytest.mark.anyio
async def test_permission_error(self, temp_file: Path): # pragma: lax no cover
"""Test reading a file without permissions."""
temp_file.chmod(0o000) # Remove all permissions
try:
resource = FileResource(
uri=temp_file.as_uri(),
name="test",
path=temp_file,
)
with pytest.raises(ValueError, match="Error reading file"):
await resource.read()
finally:
temp_file.chmod(0o644) # Restore permissions
@@ -0,0 +1,263 @@
import threading
import anyio
import anyio.from_thread
import pytest
from inline_snapshot import snapshot
from mcp_types import InputRequiredResult
from pydantic import BaseModel
from mcp.server.mcpserver.resources import FunctionResource
class TestFunctionResource:
"""Test FunctionResource functionality."""
def test_function_resource_creation(self):
"""Test creating a FunctionResource."""
def my_func() -> str: # pragma: no cover
return "test content"
resource = FunctionResource(
uri="fn://test",
name="test",
description="test function",
fn=my_func,
)
assert str(resource.uri) == "fn://test"
assert resource.name == "test"
assert resource.description == "test function"
assert resource.mime_type == "text/plain" # default
assert resource.fn == my_func
@pytest.mark.anyio
async def test_read_text(self):
"""Test reading text from a FunctionResource."""
def get_data() -> str:
return "Hello, world!"
resource = FunctionResource(
uri="function://test",
name="test",
fn=get_data,
)
content = await resource.read()
assert content == "Hello, world!"
assert resource.mime_type == "text/plain"
@pytest.mark.anyio
async def test_read_binary(self):
"""Test reading binary data from a FunctionResource."""
def get_data() -> bytes:
return b"Hello, world!"
resource = FunctionResource(
uri="function://test",
name="test",
fn=get_data,
)
content = await resource.read()
assert content == b"Hello, world!"
@pytest.mark.anyio
async def test_json_conversion(self):
"""Test automatic JSON conversion of non-string results."""
def get_data() -> dict[str, str]:
return {"key": "value"}
resource = FunctionResource(
uri="function://test",
name="test",
fn=get_data,
)
content = await resource.read()
assert isinstance(content, str)
assert '"key": "value"' in content
@pytest.mark.anyio
async def test_error_handling(self):
"""Test error handling in FunctionResource."""
def failing_func() -> str:
raise ValueError("Test error")
resource = FunctionResource(
uri="function://test",
name="test",
fn=failing_func,
)
with pytest.raises(ValueError, match="Error reading resource function://test"):
await resource.read()
@pytest.mark.anyio
async def test_basemodel_conversion(self):
"""Test handling of BaseModel types."""
class MyModel(BaseModel):
name: str
resource = FunctionResource(
uri="function://test",
name="test",
fn=lambda: MyModel(name="test"),
)
content = await resource.read()
assert content == '{\n "name": "test"\n}'
@pytest.mark.anyio
async def test_custom_type_conversion(self):
"""Test handling of custom types."""
class CustomData:
def __str__(self) -> str:
return "custom data"
def get_data() -> CustomData:
return CustomData()
resource = FunctionResource(
uri="function://test",
name="test",
fn=get_data,
)
content = await resource.read()
assert isinstance(content, str)
@pytest.mark.anyio
async def test_async_read_text(self):
"""Test reading text from async FunctionResource."""
async def get_data() -> str:
return "Hello, world!"
resource = FunctionResource(
uri="function://test",
name="test",
fn=get_data,
)
content = await resource.read()
assert content == "Hello, world!"
assert resource.mime_type == "text/plain"
@pytest.mark.anyio
async def test_from_function(self):
"""Test creating a FunctionResource from a function."""
async def get_data() -> str: # pragma: no cover
"""get_data returns a string"""
return "Hello, world!"
resource = FunctionResource.from_function(
fn=get_data,
uri="function://test",
name="test",
)
assert resource.description == "get_data returns a string"
assert resource.mime_type == "text/plain"
assert resource.name == "test"
assert resource.uri == "function://test"
class TestFunctionResourceMetadata:
def test_from_function_with_metadata(self):
# from_function() accepts meta dict and stores it on the resource for static resources
def get_data() -> str: # pragma: no cover
return "test data"
metadata = {"cache_ttl": 300, "tags": ["data", "readonly"]}
resource = FunctionResource.from_function(
fn=get_data,
uri="resource://data",
meta=metadata,
)
assert resource.meta is not None
assert resource.meta == metadata
assert resource.meta["cache_ttl"] == 300
assert "data" in resource.meta["tags"]
assert "readonly" in resource.meta["tags"]
def test_from_function_without_metadata(self):
# meta parameter is optional and defaults to None for backward compatibility
def get_data() -> str: # pragma: no cover
return "test data"
resource = FunctionResource.from_function(
fn=get_data,
uri="resource://data",
)
assert resource.meta is None
@pytest.mark.anyio
async def test_sync_fn_runs_in_worker_thread():
"""Sync resource functions must run in a worker thread, not the event loop."""
main_thread = threading.get_ident()
fn_thread: list[int] = []
def blocking_fn() -> str:
fn_thread.append(threading.get_ident())
return "data"
resource = FunctionResource(uri="resource://test", name="test", fn=blocking_fn)
result = await resource.read()
assert result == "data"
assert fn_thread[0] != main_thread
@pytest.mark.anyio
async def test_sync_fn_does_not_block_event_loop():
"""A blocking sync resource function must not stall the event loop.
On regression (sync runs inline), anyio.from_thread.run_sync raises
RuntimeError because there is no worker-thread context, failing fast.
"""
handler_entered = anyio.Event()
release = threading.Event()
def blocking_fn() -> str:
anyio.from_thread.run_sync(handler_entered.set)
release.wait()
return "done"
resource = FunctionResource(uri="resource://test", name="test", fn=blocking_fn)
result: list[str | bytes] = []
async def run() -> None:
result.append(await resource.read())
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
tg.start_soon(run)
await handler_entered.wait()
release.set()
assert result == ["done"]
@pytest.mark.anyio
async def test_read_rejects_an_input_required_result_from_a_static_function():
"""A static resource function returning an InputRequiredResult is a mistake (it can
never read the retry's input_responses), so read() raises instead of JSON-dumping it."""
def ask() -> InputRequiredResult:
return InputRequiredResult(request_state="round-1")
resource = FunctionResource(uri="resource://ask", name="ask", fn=ask)
with pytest.raises(ValueError) as exc:
await resource.read()
assert str(exc.value) == snapshot(
"Error reading resource resource://ask: static resources cannot return "
"InputRequiredResult; only resource template functions participate in the multi-round-trip flow"
)
@@ -0,0 +1,142 @@
import logging
from pathlib import Path
import pytest
from pydantic import AnyUrl
from mcp.server.mcpserver import Context
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
from mcp.server.mcpserver.resources import FileResource, FunctionResource, ResourceManager, ResourceTemplate
@pytest.fixture()
def temp_file(tmp_path: Path):
"""Create a temporary file for testing.
File is automatically cleaned up after the test if it still exists.
"""
tmp_file = tmp_path / "file"
tmp_file.touch()
yield tmp_file
def test_init_with_resources(temp_file: Path, caplog: pytest.LogCaptureFixture):
resource = FileResource(uri=f"file://{temp_file}", name="test", path=temp_file)
manager = ResourceManager(resources=[resource])
assert manager.list_resources() == [resource]
duplicate_resource = FileResource(uri=f"file://{temp_file}", name="duplicate", path=temp_file)
with caplog.at_level(logging.WARNING):
manager = ResourceManager(True, resources=[resource, duplicate_resource])
assert "Resource already exists" in caplog.text
assert manager.list_resources() == [resource]
def test_add_resource(temp_file: Path):
"""Test adding a resource."""
manager = ResourceManager()
resource = FileResource(uri=f"file://{temp_file}", name="test", path=temp_file)
added = manager.add_resource(resource)
assert added == resource
assert manager.list_resources() == [resource]
def test_add_duplicate_resource(temp_file: Path):
"""Test adding the same resource twice."""
manager = ResourceManager()
resource = FileResource(uri=f"file://{temp_file}", name="test", path=temp_file)
first = manager.add_resource(resource)
second = manager.add_resource(resource)
assert first == second
assert manager.list_resources() == [resource]
def test_warn_on_duplicate_resources(temp_file: Path, caplog: pytest.LogCaptureFixture):
"""Test warning on duplicate resources."""
manager = ResourceManager()
resource = FileResource(uri=f"file://{temp_file}", name="test", path=temp_file)
manager.add_resource(resource)
manager.add_resource(resource)
assert "Resource already exists" in caplog.text
def test_disable_warn_on_duplicate_resources(temp_file: Path, caplog: pytest.LogCaptureFixture):
"""Test disabling warning on duplicate resources."""
manager = ResourceManager(warn_on_duplicate_resources=False)
resource = FileResource(uri=f"file://{temp_file}", name="test", path=temp_file)
manager.add_resource(resource)
manager.add_resource(resource)
assert "Resource already exists" not in caplog.text
@pytest.mark.anyio
async def test_get_resource(temp_file: Path):
"""Test getting a resource by URI."""
manager = ResourceManager()
resource = FileResource(uri=f"file://{temp_file}", name="test", path=temp_file)
manager.add_resource(resource)
retrieved = await manager.get_resource(resource.uri, Context())
assert retrieved == resource
@pytest.mark.anyio
async def test_get_resource_from_template():
"""Test getting a resource through a template."""
manager = ResourceManager()
def greet(name: str) -> str:
return f"Hello, {name}!"
template = ResourceTemplate.from_function(fn=greet, uri_template="greet://{name}", name="greeter")
manager._templates[template.uri_template] = template
resource = await manager.get_resource(AnyUrl("greet://world"), Context())
assert isinstance(resource, FunctionResource)
content = await resource.read()
assert content == "Hello, world!"
@pytest.mark.anyio
async def test_get_unknown_resource():
"""Test getting a non-existent resource."""
manager = ResourceManager()
with pytest.raises(ResourceNotFoundError, match="Unknown resource"):
await manager.get_resource(AnyUrl("unknown://test"), Context())
def test_list_resources(temp_file: Path):
"""Test listing all resources."""
manager = ResourceManager()
resource1 = FileResource(uri=f"file://{temp_file}", name="test1", path=temp_file)
resource2 = FileResource(uri=f"file://{temp_file}2", name="test2", path=temp_file)
manager.add_resource(resource1)
manager.add_resource(resource2)
resources = manager.list_resources()
assert len(resources) == 2
assert resources == [resource1, resource2]
def get_item(id: str) -> str: ...
def test_add_template_with_metadata():
"""Test that ResourceManager.add_template() accepts and passes meta parameter."""
manager = ResourceManager()
metadata = {"source": "database", "cached": True}
template = manager.add_template(fn=get_item, uri_template="resource://items/{id}", meta=metadata)
assert template.meta is not None
assert template.meta == metadata
assert template.meta["source"] == "database"
assert template.meta["cached"] is True
def test_add_template_without_metadata():
"""Test that ResourceManager.add_template() works without meta parameter."""
manager = ResourceManager()
template = manager.add_template(fn=get_item, uri_template="resource://items/{id}")
assert template.meta is None
@@ -0,0 +1,507 @@
import json
import threading
from typing import Any
import pytest
from mcp_types import Annotations, ElicitRequest, ElicitRequestFormParams, InputRequiredResult
from pydantic import BaseModel
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.mcpserver.exceptions import ResourceError
from mcp.server.mcpserver.resources import FunctionResource, ResourceTemplate
from mcp.server.mcpserver.resources.templates import (
DEFAULT_RESOURCE_SECURITY,
ResourceSecurity,
ResourceSecurityError,
)
def _make(uri_template: str, security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY) -> ResourceTemplate:
def handler(**kwargs: Any) -> str:
raise NotImplementedError # these tests only exercise matches()
return ResourceTemplate.from_function(fn=handler, uri_template=uri_template, security=security)
def test_matches_rfc6570_reserved_expansion():
# {+path} allows / — the feature the old regex implementation couldn't support
t = _make("file://docs/{+path}")
assert t.matches("file://docs/src/main.py") == {"path": "src/main.py"}
def test_matches_rejects_encoded_slash_traversal():
# %2F decodes to / in UriTemplate.match(), giving "../../etc/passwd".
# ResourceSecurity's traversal check then rejects the '..' components.
t = _make("file://docs/{name}")
with pytest.raises(ResourceSecurityError, match="'name'"):
t.matches("file://docs/..%2F..%2Fetc%2Fpasswd")
def test_matches_rejects_path_traversal_by_default():
t = _make("file://docs/{name}")
with pytest.raises(ResourceSecurityError):
t.matches("file://docs/..")
def test_matches_rejects_path_traversal_in_reserved_var():
# Even {+path} gets the traversal check — it's semantic, not structural
t = _make("file://docs/{+path}")
with pytest.raises(ResourceSecurityError):
t.matches("file://docs/../../etc/passwd")
def test_matches_rejects_absolute_path():
t = _make("file://docs/{+path}")
with pytest.raises(ResourceSecurityError):
t.matches("file://docs//etc/passwd")
def test_matches_allows_dotdot_as_substring():
# .. is only dangerous as a path component
t = _make("git://refs/{range}")
assert t.matches("git://refs/v1.0..v2.0") == {"range": "v1.0..v2.0"}
def test_matches_exempt_params_skip_security():
policy = ResourceSecurity(exempt_params={"range"})
t = _make("git://diff/{+range}", security=policy)
assert t.matches("git://diff/../foo") == {"range": "../foo"}
def test_matches_disabled_policy_allows_traversal():
policy = ResourceSecurity(reject_path_traversal=False, reject_absolute_paths=False)
t = _make("file://docs/{name}", security=policy)
assert t.matches("file://docs/..") == {"name": ".."}
def test_matches_rejects_null_byte_by_default():
# %00 decodes to \x00 which defeats string comparisons
# ("..\x00" != "..") and can truncate in C extensions.
t = _make("file://docs/{name}")
with pytest.raises(ResourceSecurityError):
t.matches("file://docs/key%00.txt")
# Null byte also defeats the traversal check's component comparison
with pytest.raises(ResourceSecurityError):
t.matches("file://docs/..%00%2Fsecret")
def test_matches_null_byte_check_can_be_disabled():
policy = ResourceSecurity(reject_null_bytes=False)
t = _make("file://docs/{name}", security=policy)
assert t.matches("file://docs/key%00.txt") == {"name": "key\x00.txt"}
def test_security_rejection_does_not_fall_through_to_next_template():
# A strict template's security rejection must halt iteration, not
# fall through to a later permissive template. Previously matches()
# returned None for both "no match" and "security failed", making
# registration order security-critical.
strict = _make("file://docs/{name}")
lax = _make(
"file://docs/{+path}",
security=ResourceSecurity(exempt_params={"path"}),
)
uri = "file://docs/..%2Fsecrets"
# Strict matches structurally then fails security -> raises.
with pytest.raises(ResourceSecurityError) as exc:
strict.matches(uri)
assert exc.value.param == "name"
# If this raised, the resource manager never reaches the lax
# template. Verify the lax template WOULD have accepted it.
assert lax.matches(uri) == {"path": "../secrets"}
def test_matches_explode_checks_each_segment():
t = _make("api{/parts*}")
assert t.matches("api/a/b/c") == {"parts": ["a", "b", "c"]}
# Any segment with traversal rejects the whole match
with pytest.raises(ResourceSecurityError):
t.matches("api/a/../c")
def test_matches_encoded_backslash_caught_by_traversal_check():
# %5C decodes to '\\'. The traversal check normalizes '\\' to '/'
# and catches the '..' components.
t = _make("file://docs/{name}")
with pytest.raises(ResourceSecurityError):
t.matches("file://docs/..%5C..%5Csecret")
def test_matches_encoded_dots_caught_by_traversal_check():
# %2E%2E decodes to '..' which the traversal check rejects.
t = _make("file://docs/{name}")
with pytest.raises(ResourceSecurityError):
t.matches("file://docs/%2E%2E")
def test_matches_mixed_encoded_and_literal_slash():
# The literal '/' stops the simple-var regex, so the URI doesn't
# match the template at all.
t = _make("file://docs/{name}")
assert t.matches("file://docs/..%2F../etc") is None
def test_matches_encoded_slash_without_traversal_allowed():
# %2F decoding to '/' is fine when there's no traversal involved.
# UriTemplate accepts it; ResourceSecurity only blocks '..' and
# absolute paths. Handlers that need single-segment should use
# safe_join or validate explicitly.
t = _make("file://docs/{name}")
assert t.matches("file://docs/sub%2Ffile.txt") == {"name": "sub/file.txt"}
def test_matches_escapes_template_literals():
# Regression: old impl treated . as regex wildcard
t = _make("data://v1.0/{id}")
assert t.matches("data://v1.0/42") == {"id": "42"}
assert t.matches("data://v1X0/42") is None
class TestResourceTemplate:
"""Test ResourceTemplate functionality."""
def test_template_creation(self):
"""Test creating a template from a function."""
def my_func(key: str, value: int) -> dict[str, Any]:
return {"key": key, "value": value}
template = ResourceTemplate.from_function(
fn=my_func,
uri_template="test://{key}/{value}",
name="test",
)
assert template.uri_template == "test://{key}/{value}"
assert template.name == "test"
assert template.mime_type == "text/plain" # default
assert template.fn(key="test", value=42) == my_func(key="test", value=42)
def test_template_matches(self):
"""Test matching URIs against a template."""
def my_func(key: str, value: int) -> dict[str, Any]: # pragma: no cover
return {"key": key, "value": value}
template = ResourceTemplate.from_function(
fn=my_func,
uri_template="test://{key}/{value}",
name="test",
)
# Valid match
params = template.matches("test://foo/123")
assert params == {"key": "foo", "value": "123"}
# No match
assert template.matches("test://foo") is None
assert template.matches("other://foo/123") is None
@pytest.mark.anyio
async def test_create_resource(self):
"""Test creating a resource from a template."""
def my_func(key: str, value: int) -> dict[str, Any]:
return {"key": key, "value": value}
template = ResourceTemplate.from_function(
fn=my_func,
uri_template="test://{key}/{value}",
name="test",
)
resource = await template.create_resource(
"test://foo/123",
{"key": "foo", "value": 123},
Context(),
)
assert isinstance(resource, FunctionResource)
content = await resource.read()
assert isinstance(content, str)
data = json.loads(content)
assert data == {"key": "foo", "value": 123}
@pytest.mark.anyio
async def test_template_error(self):
"""Test error handling in template resource creation."""
def failing_func(x: str) -> str:
raise ValueError("Test error")
template = ResourceTemplate.from_function(
fn=failing_func,
uri_template="fail://{x}",
name="fail",
)
with pytest.raises(ResourceError, match="Error creating resource from template"):
await template.create_resource("fail://test", {"x": "test"}, Context())
@pytest.mark.anyio
async def test_async_text_resource(self):
"""Test creating a text resource from async function."""
async def greet(name: str) -> str:
return f"Hello, {name}!"
template = ResourceTemplate.from_function(
fn=greet,
uri_template="greet://{name}",
name="greeter",
)
resource = await template.create_resource(
"greet://world",
{"name": "world"},
Context(),
)
assert isinstance(resource, FunctionResource)
content = await resource.read()
assert content == "Hello, world!"
@pytest.mark.anyio
async def test_async_binary_resource(self):
"""Test creating a binary resource from async function."""
async def get_bytes(value: str) -> bytes:
return value.encode()
template = ResourceTemplate.from_function(
fn=get_bytes,
uri_template="bytes://{value}",
name="bytes",
)
resource = await template.create_resource(
"bytes://test",
{"value": "test"},
Context(),
)
assert isinstance(resource, FunctionResource)
content = await resource.read()
assert content == b"test"
@pytest.mark.anyio
async def test_basemodel_conversion(self):
"""Test handling of BaseModel types."""
class MyModel(BaseModel):
key: str
value: int
def get_data(key: str, value: int) -> MyModel:
return MyModel(key=key, value=value)
template = ResourceTemplate.from_function(
fn=get_data,
uri_template="test://{key}/{value}",
name="test",
)
resource = await template.create_resource(
"test://foo/123",
{"key": "foo", "value": 123},
Context(),
)
assert isinstance(resource, FunctionResource)
content = await resource.read()
assert isinstance(content, str)
data = json.loads(content)
assert data == {"key": "foo", "value": 123}
@pytest.mark.anyio
async def test_custom_type_conversion(self):
"""Test handling of custom types."""
class CustomData:
def __init__(self, value: str):
self.value = value
def __str__(self) -> str:
return self.value
def get_data(value: str) -> CustomData:
return CustomData(value)
template = ResourceTemplate.from_function(
fn=get_data,
uri_template="test://{value}",
name="test",
)
resource = await template.create_resource(
"test://hello",
{"value": "hello"},
Context(),
)
assert isinstance(resource, FunctionResource)
content = await resource.read()
assert content == '"hello"'
class TestResourceTemplateAnnotations:
"""Test annotations on resource templates."""
def test_template_with_annotations(self):
"""Test creating a template with annotations."""
def get_user_data(user_id: str) -> str: # pragma: no cover
return f"User {user_id}"
annotations = Annotations(priority=0.9)
template = ResourceTemplate.from_function(
fn=get_user_data, uri_template="resource://users/{user_id}", annotations=annotations
)
assert template.annotations is not None
assert template.annotations.priority == 0.9
def test_template_without_annotations(self):
"""Test that annotations are optional for templates."""
def get_user_data(user_id: str) -> str: # pragma: no cover
return f"User {user_id}"
template = ResourceTemplate.from_function(fn=get_user_data, uri_template="resource://users/{user_id}")
assert template.annotations is None
@pytest.mark.anyio
async def test_template_annotations_in_mcpserver(self):
"""Test template annotations via an MCPServer decorator."""
mcp = MCPServer()
@mcp.resource("resource://dynamic/{id}", annotations=Annotations(audience=["user"], priority=0.7))
def get_dynamic(id: str) -> str: # pragma: no cover
"""A dynamic annotated resource."""
return f"Data for {id}"
templates = await mcp.list_resource_templates()
assert len(templates) == 1
assert templates[0].annotations is not None
assert templates[0].annotations.audience == ["user"]
assert templates[0].annotations.priority == 0.7
@pytest.mark.anyio
async def test_template_created_resources_inherit_annotations(self):
"""Test that resources created from templates inherit annotations."""
def get_item(item_id: str) -> str:
return f"Item {item_id}"
annotations = Annotations(priority=0.6)
template = ResourceTemplate.from_function(
fn=get_item, uri_template="resource://items/{item_id}", annotations=annotations
)
# Create a resource from the template
resource = await template.create_resource("resource://items/123", {"item_id": "123"}, Context())
assert not isinstance(resource, InputRequiredResult)
# The resource should inherit the template's annotations
assert resource.annotations is not None
assert resource.annotations.priority == 0.6
# Verify the resource works correctly
content = await resource.read()
assert content == "Item 123"
class TestResourceTemplateMetadata:
"""Test ResourceTemplate meta handling."""
def test_template_from_function_with_metadata(self):
"""Test that ResourceTemplate.from_function() accepts and stores meta parameter."""
def get_user(user_id: str) -> str: # pragma: no cover
return f"User {user_id}"
metadata = {"requires_auth": True, "rate_limit": 100}
template = ResourceTemplate.from_function(
fn=get_user,
uri_template="resource://users/{user_id}",
meta=metadata,
)
assert template.meta is not None
assert template.meta == metadata
assert template.meta["requires_auth"] is True
assert template.meta["rate_limit"] == 100
@pytest.mark.anyio
async def test_template_created_resources_inherit_metadata(self):
"""Test that resources created from templates inherit meta from template."""
def get_item(item_id: str) -> str:
return f"Item {item_id}"
metadata = {"category": "inventory", "cacheable": True}
template = ResourceTemplate.from_function(
fn=get_item,
uri_template="resource://items/{item_id}",
meta=metadata,
)
# Create a resource from the template
resource = await template.create_resource("resource://items/123", {"item_id": "123"}, Context())
# The resource should inherit the template's metadata
assert resource.meta is not None
assert resource.meta == metadata
assert resource.meta["category"] == "inventory"
assert resource.meta["cacheable"] is True
@pytest.mark.anyio
async def test_sync_fn_runs_in_worker_thread():
"""Sync template functions must run in a worker thread, not the event loop."""
main_thread = threading.get_ident()
fn_thread: list[int] = []
def blocking_fn(name: str) -> str:
fn_thread.append(threading.get_ident())
return f"hello {name}"
template = ResourceTemplate.from_function(fn=blocking_fn, uri_template="test://{name}")
resource = await template.create_resource("test://world", {"name": "world"}, Context())
assert isinstance(resource, FunctionResource)
assert await resource.read() == "hello world"
assert fn_thread[0] != main_thread
@pytest.mark.anyio
async def test_create_resource_passes_input_required_result_through_unchanged():
"""create_resource returns the InputRequiredResult the template function returned
instead of wrapping it in a FunctionResource (SEP-2322 multi-round-trip pass-through)."""
sentinel = InputRequiredResult(
input_requests={
"who": ElicitRequest(
params=ElicitRequestFormParams(
message="Who is this for?",
requested_schema={
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
)
)
}
)
def ask(topic: str) -> InputRequiredResult:
return sentinel
template = ResourceTemplate.from_function(fn=ask, uri_template="ask://{topic}")
result = await template.create_resource("ask://databases", {"topic": "databases"}, Context())
assert result is sentinel
@@ -0,0 +1,240 @@
import pytest
from mcp_types import Annotations
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.resources import FunctionResource, Resource
class TestResourceValidation:
"""Test base Resource validation."""
def test_resource_uri_accepts_any_string(self):
"""Test that URI field accepts any string per MCP spec."""
def dummy_func() -> str: # pragma: no cover
return "data"
# Valid URI
resource = FunctionResource(
uri="http://example.com/data",
name="test",
fn=dummy_func,
)
assert resource.uri == "http://example.com/data"
# Relative path - now accepted per MCP spec
resource = FunctionResource(
uri="users/me",
name="test",
fn=dummy_func,
)
assert resource.uri == "users/me"
# Custom scheme
resource = FunctionResource(
uri="custom://resource",
name="test",
fn=dummy_func,
)
assert resource.uri == "custom://resource"
def test_resource_name_from_uri(self):
"""Test name is extracted from URI if not provided."""
def dummy_func() -> str: # pragma: no cover
return "data"
resource = FunctionResource(
uri="resource://my-resource",
fn=dummy_func,
)
assert resource.name == "resource://my-resource"
def test_resource_name_validation(self):
"""Test name validation."""
def dummy_func() -> str: # pragma: no cover
return "data"
# Must provide either name or URI
with pytest.raises(ValueError, match="Either name or uri must be provided"):
FunctionResource(
fn=dummy_func,
)
# Explicit name takes precedence over URI
resource = FunctionResource(
uri="resource://uri-name",
name="explicit-name",
fn=dummy_func,
)
assert resource.name == "explicit-name"
def test_resource_mime_type(self):
"""Test mime type handling."""
def dummy_func() -> str: # pragma: no cover
return "data"
# Default mime type
resource = FunctionResource(
uri="resource://test",
fn=dummy_func,
)
assert resource.mime_type == "text/plain"
# Custom mime type
resource = FunctionResource(
uri="resource://test",
fn=dummy_func,
mime_type="application/json",
)
assert resource.mime_type == "application/json"
# RFC 2045 quoted parameter value (gh-1756)
resource = FunctionResource(
uri="resource://test",
fn=dummy_func,
mime_type='text/plain; charset="utf-8"',
)
assert resource.mime_type == 'text/plain; charset="utf-8"'
@pytest.mark.anyio
async def test_resource_read_abstract(self):
"""Test that Resource.read() is abstract."""
class ConcreteResource(Resource):
pass
with pytest.raises(TypeError, match="abstract method"):
ConcreteResource(uri="test://test", name="test") # type: ignore
class TestResourceAnnotations:
"""Test annotations on resources."""
def test_resource_with_annotations(self):
"""Test creating a resource with annotations."""
def get_data() -> str: # pragma: no cover
return "data"
annotations = Annotations(audience=["user"], priority=0.8)
resource = FunctionResource.from_function(fn=get_data, uri="resource://test", annotations=annotations)
assert resource.annotations is not None
assert resource.annotations.audience == ["user"]
assert resource.annotations.priority == 0.8
def test_resource_without_annotations(self):
"""Test that annotations are optional."""
def get_data() -> str: # pragma: no cover
return "data"
resource = FunctionResource.from_function(fn=get_data, uri="resource://test")
assert resource.annotations is None
@pytest.mark.anyio
async def test_resource_annotations_in_mcpserver(self):
"""Test resource annotations via MCPServer decorator."""
mcp = MCPServer()
@mcp.resource("resource://annotated", annotations=Annotations(audience=["assistant"], priority=0.5))
def get_annotated() -> str: # pragma: no cover
"""An annotated resource."""
return "annotated data"
resources = await mcp.list_resources()
assert len(resources) == 1
assert resources[0].annotations is not None
assert resources[0].annotations.audience == ["assistant"]
assert resources[0].annotations.priority == 0.5
@pytest.mark.anyio
async def test_resource_annotations_with_both_audiences(self):
"""Test resource with both user and assistant audience."""
mcp = MCPServer()
@mcp.resource("resource://both", annotations=Annotations(audience=["user", "assistant"], priority=1.0))
def get_both() -> str: # pragma: no cover
return "for everyone"
resources = await mcp.list_resources()
assert resources[0].annotations is not None
assert resources[0].annotations.audience == ["user", "assistant"]
assert resources[0].annotations.priority == 1.0
class TestAnnotationsValidation:
"""Test validation of annotation values."""
def test_priority_validation(self):
"""Test that priority is validated to be between 0.0 and 1.0."""
# Valid priorities
Annotations(priority=0.0)
Annotations(priority=0.5)
Annotations(priority=1.0)
# Invalid priorities should raise validation error
with pytest.raises(Exception): # Pydantic validation error
Annotations(priority=-0.1)
with pytest.raises(Exception):
Annotations(priority=1.1)
def test_audience_validation(self):
"""Test that audience only accepts valid roles."""
# Valid audiences
Annotations(audience=["user"])
Annotations(audience=["assistant"])
Annotations(audience=["user", "assistant"])
Annotations(audience=[])
# Invalid roles should raise validation error
with pytest.raises(Exception): # Pydantic validation error
Annotations(audience=["invalid_role"]) # type: ignore
class TestResourceMetadata:
"""Test metadata field on base Resource class."""
def test_resource_with_metadata(self):
"""Test that Resource base class accepts meta parameter."""
def dummy_func() -> str: # pragma: no cover
return "data"
metadata = {"version": "1.0", "category": "test"}
resource = FunctionResource(
uri="resource://test",
name="test",
fn=dummy_func,
meta=metadata,
)
assert resource.meta is not None
assert resource.meta == metadata
assert resource.meta["version"] == "1.0"
assert resource.meta["category"] == "test"
def test_resource_without_metadata(self):
"""Test that meta field defaults to None."""
def dummy_func() -> str: # pragma: no cover
return "data"
resource = FunctionResource(
uri="resource://test",
name="test",
fn=dummy_func,
)
assert resource.meta is None
@@ -0,0 +1,132 @@
import json
from pathlib import Path
import pytest
from mcp_types import InputRequiredResult
from mcp.server.mcpserver import MCPServer
@pytest.fixture()
def test_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""Create a temporary directory with test files."""
tmp = tmp_path_factory.mktemp("test_files")
# Create test files
(tmp / "example.py").write_text("print('hello world')")
(tmp / "readme.md").write_text("# Test Directory\nThis is a test.")
(tmp / "config.json").write_text('{"test": true}')
return tmp
@pytest.fixture
def mcp() -> MCPServer:
mcp = MCPServer()
return mcp
@pytest.fixture(autouse=True)
def resources(mcp: MCPServer, test_dir: Path) -> MCPServer:
@mcp.resource("dir://test_dir")
def list_test_dir() -> list[str]:
"""List the files in the test directory"""
return [str(f) for f in test_dir.iterdir()]
@mcp.resource("file://test_dir/example.py")
def read_example_py() -> str:
"""Read the example.py file"""
try:
return (test_dir / "example.py").read_text()
except FileNotFoundError:
return "File not found"
@mcp.resource("file://test_dir/readme.md")
def read_readme_md() -> str:
"""Read the readme.md file"""
try: # pragma: no cover
return (test_dir / "readme.md").read_text()
except FileNotFoundError: # pragma: no cover
return "File not found"
@mcp.resource("file://test_dir/config.json")
def read_config_json() -> str:
"""Read the config.json file"""
try: # pragma: no cover
return (test_dir / "config.json").read_text()
except FileNotFoundError: # pragma: no cover
return "File not found"
return mcp
@pytest.fixture(autouse=True)
def tools(mcp: MCPServer, test_dir: Path) -> MCPServer:
@mcp.tool()
def delete_file(path: str) -> bool:
# ensure path is in test_dir
if Path(path).resolve().parent != test_dir: # pragma: no cover
raise ValueError(f"Path must be in test_dir: {path}")
Path(path).unlink()
return True
return mcp
@pytest.mark.anyio
async def test_list_resources(mcp: MCPServer):
resources = await mcp.list_resources()
assert len(resources) == 4
assert [str(r.uri) for r in resources] == [
"dir://test_dir",
"file://test_dir/example.py",
"file://test_dir/readme.md",
"file://test_dir/config.json",
]
@pytest.mark.anyio
async def test_read_resource_dir(mcp: MCPServer):
res_iter = await mcp.read_resource("dir://test_dir")
assert not isinstance(res_iter, InputRequiredResult)
res_list = list(res_iter)
assert len(res_list) == 1
res = res_list[0]
assert res.mime_type == "text/plain"
files = json.loads(res.content)
assert sorted([Path(f).name for f in files]) == [
"config.json",
"example.py",
"readme.md",
]
@pytest.mark.anyio
async def test_read_resource_file(mcp: MCPServer):
res_iter = await mcp.read_resource("file://test_dir/example.py")
assert not isinstance(res_iter, InputRequiredResult)
res_list = list(res_iter)
assert len(res_list) == 1
res = res_list[0]
assert res.content == "print('hello world')"
@pytest.mark.anyio
async def test_delete_file(mcp: MCPServer, test_dir: Path):
await mcp.call_tool("delete_file", arguments={"path": str(test_dir / "example.py")})
assert not (test_dir / "example.py").exists()
@pytest.mark.anyio
async def test_delete_file_and_check_resources(mcp: MCPServer, test_dir: Path):
await mcp.call_tool("delete_file", arguments={"path": str(test_dir / "example.py")})
res_iter = await mcp.read_resource("file://test_dir/example.py")
assert not isinstance(res_iter, InputRequiredResult)
res_list = list(res_iter)
assert len(res_list) == 1
res = res_list[0]
assert res.content == "File not found"
+405
View File
@@ -0,0 +1,405 @@
"""Test the elicitation feature over the in-memory client transport."""
from typing import Any, Literal
import mcp_types as types
import pytest
from mcp_types import ElicitRequestParams, ElicitResult, TextContent
from pydantic import BaseModel, Field
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.client.session import ElicitationFnT
from mcp.server.mcpserver import Context, MCPServer
# Shared schema for basic tests
class AnswerSchema(BaseModel):
answer: str = Field(description="The user's answer to the question")
def create_ask_user_tool(mcp: MCPServer):
"""Create a standard ask_user tool that handles all elicitation responses."""
@mcp.tool(description="A tool that uses elicitation")
async def ask_user(prompt: str, ctx: Context) -> str:
result = await ctx.elicit(message=f"Tool wants to ask: {prompt}", schema=AnswerSchema)
if result.action == "accept" and result.data:
return f"User answered: {result.data.answer}"
elif result.action == "decline":
return "User declined to answer"
else: # pragma: no cover
return "User cancelled"
return ask_user
async def call_tool_and_assert(
mcp: MCPServer,
elicitation_callback: ElicitationFnT,
tool_name: str,
args: dict[str, Any],
expected_text: str | None = None,
text_contains: list[str] | None = None,
):
"""Helper to create session, call tool, and assert result."""
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool(tool_name, args)
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
if expected_text is not None:
assert result.content[0].text == expected_text
elif text_contains is not None: # pragma: no branch
for substring in text_contains:
assert substring in result.content[0].text
return result
@pytest.mark.anyio
async def test_elicitation_accept_returns_the_users_answer_to_the_tool():
"""An accepted elicitation delivers the user's content back to the requesting tool."""
mcp = MCPServer(name="ElicitationServer")
create_ask_user_tool(mcp)
# Create a custom handler for elicitation requests
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
if params.message == "Tool wants to ask: What is your name?":
return ElicitResult(action="accept", content={"answer": "Test User"})
else: # pragma: no cover
raise ValueError(f"Unexpected elicitation message: {params.message}")
await call_tool_and_assert(
mcp, elicitation_callback, "ask_user", {"prompt": "What is your name?"}, "User answered: Test User"
)
@pytest.mark.anyio
async def test_elicitation_decline_reaches_the_tool_without_content():
"""A declined elicitation reports the decline to the tool, with no content attached."""
mcp = MCPServer(name="ElicitationDeclineServer")
create_ask_user_tool(mcp)
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(action="decline")
await call_tool_and_assert(
mcp, elicitation_callback, "ask_user", {"prompt": "What is your name?"}, "User declined to answer"
)
@pytest.mark.anyio
async def test_elicitation_schema_validation():
"""Test that elicitation schemas must only contain primitive types."""
mcp = MCPServer(name="ValidationTestServer")
def create_validation_tool(name: str, schema_class: type[BaseModel]):
@mcp.tool(name=name, description=f"Tool testing {name}")
async def tool(ctx: Context) -> str:
try:
await ctx.elicit(message="This should fail validation", schema=schema_class)
return "Should not reach here" # pragma: no cover
except TypeError as e:
return f"Validation failed as expected: {str(e)}"
return tool
# Test cases for invalid schemas
class InvalidListSchema(BaseModel):
numbers: list[int] = Field(description="List of numbers")
class NestedModel(BaseModel):
value: str
class InvalidNestedSchema(BaseModel):
nested: NestedModel = Field(description="Nested model")
create_validation_tool("invalid_list", InvalidListSchema)
create_validation_tool("nested_model", InvalidNestedSchema)
# Dummy callback (won't be called due to validation failure)
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams): # pragma: no cover
return ElicitResult(action="accept", content={})
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
# Test both invalid schemas
for tool_name, field_name in [("invalid_list", "numbers"), ("nested_model", "nested")]:
result = await client.call_tool(tool_name, {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert "Validation failed as expected" in result.content[0].text
assert field_name in result.content[0].text
@pytest.mark.anyio
async def test_elicitation_with_optional_fields():
"""Test that Optional fields work correctly in elicitation schemas."""
mcp = MCPServer(name="OptionalFieldServer")
class OptionalSchema(BaseModel):
required_name: str = Field(description="Your name (required)")
optional_age: int | None = Field(default=None, description="Your age (optional)")
optional_email: str | None = Field(default=None, description="Your email (optional)")
subscribe: bool | None = Field(default=False, description="Subscribe to newsletter?")
@mcp.tool(description="Tool with optional fields")
async def optional_tool(ctx: Context) -> str:
result = await ctx.elicit(message="Please provide your information", schema=OptionalSchema)
if result.action == "accept" and result.data:
info = [f"Name: {result.data.required_name}"]
if result.data.optional_age is not None:
info.append(f"Age: {result.data.optional_age}")
if result.data.optional_email is not None:
info.append(f"Email: {result.data.optional_email}")
info.append(f"Subscribe: {result.data.subscribe}")
return ", ".join(info)
else: # pragma: no cover
return f"User {result.action}"
# Test cases with different field combinations
test_cases: list[tuple[dict[str, Any], str]] = [
(
# All fields provided
{"required_name": "John Doe", "optional_age": 30, "optional_email": "john@example.com", "subscribe": True},
"Name: John Doe, Age: 30, Email: john@example.com, Subscribe: True",
),
(
# Only required fields
{"required_name": "Jane Smith"},
"Name: Jane Smith, Subscribe: False",
),
]
for content, expected in test_cases:
async def callback(context: ClientRequestContext, params: ElicitRequestParams):
assert isinstance(params, types.ElicitRequestFormParams)
# Optional fields render as the bare primitive (no anyOf), absent from `required`.
assert params.requested_schema["properties"]["optional_age"] == {
"type": "integer",
"title": "Optional Age",
"description": "Your age (optional)",
}
assert params.requested_schema["required"] == ["required_name"]
return ElicitResult(action="accept", content=content)
await call_tool_and_assert(mcp, callback, "optional_tool", {}, expected)
# Test invalid optional field
class InvalidOptionalSchema(BaseModel):
name: str = Field(description="Name")
optional_list: list[int] | None = Field(default=None, description="Invalid optional list")
@mcp.tool(description="Tool with invalid optional field")
async def invalid_optional_tool(ctx: Context) -> str:
try:
await ctx.elicit(message="This should fail", schema=InvalidOptionalSchema)
return "Should not reach here" # pragma: no cover
except TypeError as e:
return f"Validation failed: {str(e)}"
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams): # pragma: no cover
return ElicitResult(action="accept", content={})
await call_tool_and_assert(
mcp,
elicitation_callback,
"invalid_optional_tool",
{},
text_contains=["Validation failed:", "optional_list"],
)
# Bare `list[str]` renders without enum items and so is not a spec MultiSelectEnumSchema.
class BareListSchema(BaseModel):
name: str = Field(description="Name")
tags: list[str] = Field(description="Tags")
def make_reject_tool(tool_name: str, schema_cls: type[BaseModel]) -> None:
@mcp.tool(name=tool_name, description="Tool with a rejected field")
async def _tool(ctx: Context) -> str:
try:
await ctx.elicit(message="Provide value", schema=schema_cls)
except TypeError as e:
return f"Validation failed: {str(e)}"
raise NotImplementedError
make_reject_tool("bare_list_tool", BareListSchema)
await call_tool_and_assert(
mcp, elicitation_callback, "bare_list_tool", {}, text_contains=["Validation failed:", "tags"]
)
# A union of two primitives renders as `anyOf`, outside `PrimitiveSchemaDefinition`.
class MultiPrimitiveSchema(BaseModel):
value: int | str = Field(description="Value")
make_reject_tool("multi_primitive_tool", MultiPrimitiveSchema)
await call_tool_and_assert(
mcp, elicitation_callback, "multi_primitive_tool", {}, text_contains=["Validation failed:", "value"]
)
@pytest.mark.anyio
async def test_elicitation_with_default_values():
"""Test that default values work correctly in elicitation schemas and are included in JSON."""
mcp = MCPServer(name="DefaultValuesServer")
class DefaultsSchema(BaseModel):
name: str = Field(default="Guest", description="User name")
age: int = Field(default=18, description="User age")
subscribe: bool = Field(default=True, description="Subscribe to newsletter")
email: str = Field(description="Email address (required)")
@mcp.tool(description="Tool with default values")
async def defaults_tool(ctx: Context) -> str:
result = await ctx.elicit(message="Please provide your information", schema=DefaultsSchema)
if result.action == "accept" and result.data:
return (
f"Name: {result.data.name}, Age: {result.data.age}, "
f"Subscribe: {result.data.subscribe}, Email: {result.data.email}"
)
else: # pragma: no cover
return f"User {result.action}"
# First verify that defaults are present in the JSON schema sent to clients
async def callback_schema_verify(context: ClientRequestContext, params: ElicitRequestParams):
# Verify the schema includes defaults
assert isinstance(params, types.ElicitRequestFormParams), "Expected form mode elicitation"
schema = params.requested_schema
props = schema["properties"]
assert props["name"]["default"] == "Guest"
assert props["age"]["default"] == 18
assert props["subscribe"]["default"] is True
assert "default" not in props["email"] # Required field has no default
return ElicitResult(action="accept", content={"email": "test@example.com"})
await call_tool_and_assert(
mcp,
callback_schema_verify,
"defaults_tool",
{},
"Name: Guest, Age: 18, Subscribe: True, Email: test@example.com",
)
# Test overriding defaults
async def callback_override(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(
action="accept", content={"email": "john@example.com", "name": "John", "age": 25, "subscribe": False}
)
await call_tool_and_assert(
mcp, callback_override, "defaults_tool", {}, "Name: John, Age: 25, Subscribe: False, Email: john@example.com"
)
@pytest.mark.anyio
async def test_elicitation_with_enum_titles():
"""Test elicitation with enum schemas using oneOf/anyOf for titles."""
mcp = MCPServer(name="ColorPreferencesApp")
# Test single-select with titles using oneOf
class FavoriteColorSchema(BaseModel):
user_name: str = Field(description="Your name")
favorite_color: str = Field(
description="Select your favorite color",
json_schema_extra={
"oneOf": [
{"const": "red", "title": "Red"},
{"const": "green", "title": "Green"},
{"const": "blue", "title": "Blue"},
{"const": "yellow", "title": "Yellow"},
]
},
)
@mcp.tool(description="Single color selection")
async def select_favorite_color(ctx: Context) -> str:
result = await ctx.elicit(message="Select your favorite color", schema=FavoriteColorSchema)
if result.action == "accept" and result.data:
return f"User: {result.data.user_name}, Favorite: {result.data.favorite_color}"
return f"User {result.action}" # pragma: no cover
# Test legacy enumNames format
class LegacyColorSchema(BaseModel):
user_name: str = Field(description="Your name")
color: str = Field(
description="Select a color",
json_schema_extra={"enum": ["red", "green", "blue"], "enumNames": ["Red", "Green", "Blue"]},
)
@mcp.tool(description="Legacy enum format")
async def select_color_legacy(ctx: Context) -> str:
result = await ctx.elicit(message="Select a color (legacy format)", schema=LegacyColorSchema)
if result.action == "accept" and result.data:
return f"User: {result.data.user_name}, Color: {result.data.color}"
return f"User {result.action}" # pragma: no cover
# Test multi-select with titles using items.anyOf
class FavoriteColorsSchema(BaseModel):
user_name: str = Field(description="Your name")
favorite_colors: list[str] = Field(
description="Select your favorite colors",
json_schema_extra={
"items": {
"anyOf": [
{"const": "red", "title": "Red"},
{"const": "green", "title": "Green"},
{"const": "blue", "title": "Blue"},
{"const": "yellow", "title": "Yellow"},
]
}
},
)
@mcp.tool(description="Multiple color selection")
async def select_favorite_colors(ctx: Context) -> str:
result = await ctx.elicit(message="Select your favorite colors", schema=FavoriteColorsSchema)
if result.action == "accept" and result.data:
return f"User: {result.data.user_name}, Colors: {', '.join(result.data.favorite_colors)}"
raise NotImplementedError
async def enum_callback(context: ClientRequestContext, params: ElicitRequestParams):
if "colors" in params.message:
return ElicitResult(action="accept", content={"user_name": "Bob", "favorite_colors": ["red", "green"]})
if "legacy" in params.message:
return ElicitResult(action="accept", content={"user_name": "Charlie", "color": "green"})
return ElicitResult(action="accept", content={"user_name": "Alice", "favorite_color": "blue"})
# Test single-select with titles
await call_tool_and_assert(mcp, enum_callback, "select_favorite_color", {}, "User: Alice, Favorite: blue")
# Test multi-select with titles
await call_tool_and_assert(mcp, enum_callback, "select_favorite_colors", {}, "User: Bob, Colors: red, green")
# Test legacy enumNames format
await call_tool_and_assert(mcp, enum_callback, "select_color_legacy", {}, "User: Charlie, Color: green")
@pytest.mark.anyio
async def test_elicitation_literal_field_renders_as_a_spec_enum_schema():
"""`Literal[...]` and `list[Literal[...]]` render as the spec's enum schemas and pass the gate."""
mcp = MCPServer(name="LiteralServer")
class LiteralSchema(BaseModel):
size: Literal["s", "m", "l"] = Field(description="Size")
extras: list[Literal["a", "b"]] = Field(description="Extras")
@mcp.tool(description="Literal selection")
async def pick(ctx: Context) -> str:
result = await ctx.elicit(message="Pick", schema=LiteralSchema)
if result.action == "accept" and result.data:
return f"{result.data.size}:{','.join(result.data.extras)}"
raise NotImplementedError
async def callback(context: ClientRequestContext, params: ElicitRequestParams):
assert isinstance(params, types.ElicitRequestFormParams)
assert params.requested_schema["properties"]["size"]["enum"] == ["s", "m", "l"]
assert params.requested_schema["properties"]["extras"]["items"]["enum"] == ["a", "b"]
return ElicitResult(action="accept", content={"size": "m", "extras": ["a"]})
await call_tool_and_assert(mcp, callback, "pick", {}, "m:a")
+441
View File
@@ -0,0 +1,441 @@
"""Tests for the core SEP-2133 extension API (`Extension`, `MCPServer` wiring).
These exercise the closed set of extension contribution kinds - tools,
resources, request methods, and the single `tools/call` interceptor - through
the highest-level public surface (in-memory `Client`), plus the
`compose_tool_call_interceptor` helper directly.
"""
from typing import Any, Literal, cast
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
METHOD_NOT_FOUND,
MISSING_REQUIRED_CLIENT_CAPABILITY,
CallToolResult,
TextContent,
)
from mcp.client import advertise
from mcp.client.client import Client
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
from mcp.server.extension import (
Extension,
MethodBinding,
ResourceBinding,
ToolBinding,
compose_tool_call_interceptor,
)
from mcp.server.mcpserver import Context, MCPServer, require_client_extension
from mcp.server.mcpserver.resources import TextResource
from mcp.shared.exceptions import MCPError
pytestmark = pytest.mark.anyio
_TOOL_META: dict[str, Any] = {"com.example/marker": {"v": 1}}
class _AdditiveExt(Extension):
"""Override `tools()`/`resources()` only - a purely additive extension."""
identifier = "com.example/additive"
def tools(self):
def ping() -> str:
"""Reply with pong."""
return "pong"
return [ToolBinding(fn=ping, meta=_TOOL_META)]
def resources(self):
return [ResourceBinding(resource=TextResource(uri="ext://greeting", name="greeting", text="hello"))]
class _SettingsExt(Extension):
"""Override `settings()` so the extension advertises a non-empty settings map."""
identifier = "com.example/settings"
def settings(self) -> dict[str, Any]:
return {"feature": {"enabled": True}}
class _PingParams(types.RequestParams):
pass
class _PingResult(types.Result):
pong: bool
class _PingRequest(types.Request[_PingParams, Literal["com.example/ping"]]):
method: Literal["com.example/ping"] = "com.example/ping"
params: _PingParams
async def _pong_handler(ctx: ServerRequestContext[Any, Any], params: _PingParams) -> _PingResult:
"""The shared `com.example/ping` handler (dispatched by the reachability test)."""
return _PingResult(pong=True)
class _MethodExt(Extension):
"""Override `methods()` to serve a new vendor request verb."""
identifier = "com.example/method"
def methods(self) -> list[MethodBinding]:
return [MethodBinding("com.example/ping", _PingParams, _pong_handler)]
class _ReplacingExt(Extension):
"""Override `intercept_tool_call()` to short-circuit with a fixed result."""
identifier = "com.example/replacing"
async def intercept_tool_call(
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
) -> HandlerResult:
return CallToolResult(content=[TextContent(type="text", text="intercepted")])
class _PassThroughExt(Extension):
"""Override `intercept_tool_call()` but always delegate to `call_next` unchanged."""
identifier = "com.example/passthrough"
async def intercept_tool_call(
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
) -> HandlerResult:
return await call_next(ctx)
class _DefaultExt(Extension):
"""Override nothing - relies on the base `intercept_tool_call` default (pass through)."""
identifier = "com.example/default"
class _RecordingExt(Extension):
"""Override `intercept_tool_call()` to record `(identifier, tool_name)` then pass through."""
def __init__(self, identifier: str, log: list[tuple[str, str]]) -> None:
self.identifier = identifier
self._log = log
async def intercept_tool_call(
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
) -> HandlerResult:
self._log.append((self.identifier, params.name))
return await call_next(ctx)
def _echo(value: str) -> str:
"""Echo the input value (shared tool body across interceptor tests)."""
return value
async def test_additive_extension_registers_its_tool_and_resource() -> None:
"""SDK-defined: an `Extension` overriding `tools()`/`resources()` surfaces both
through `MCPServer`'s normal `list_tools`/`list_resources`, and the tool's
`_meta` round-trips equal to the exact dict the binding carried (identity can't
hold - the value is JSON-serialized over the transport)."""
server = MCPServer("test", extensions=[_AdditiveExt()])
async with Client(server) as client:
tools = await client.list_tools()
resources = await client.list_resources()
called = await client.call_tool("ping", {})
assert [t.name for t in tools.tools] == ["ping"]
assert tools.tools[0].meta == _TOOL_META
assert called == snapshot(CallToolResult(content=[TextContent(text="pong")], structured_content={"result": "pong"}))
assert resources == snapshot(
types.ListResourcesResult(
resources=[types.Resource(name="greeting", uri="ext://greeting", mime_type="text/plain")]
)
)
async def test_extension_settings_advertised_under_server_capabilities() -> None:
"""SDK-defined: `settings()` rides `server/discover` and lands under
`server_capabilities.extensions[identifier]` on the modern (`auto`) path."""
server = MCPServer("test", extensions=[_SettingsExt()])
async with Client(server, mode="auto") as client:
extensions = client.server_capabilities.extensions
assert extensions == snapshot({"com.example/settings": {"feature": {"enabled": True}}})
async def test_extension_settings_dropped_on_legacy_handshake() -> None:
"""Pinned gap: the 2025 `ServerCapabilities` wire schema has no `extensions`
field, so a legacy `initialize` handshake drops the advertised extension even
though the modern `auto` path carries it."""
server = MCPServer("test", extensions=[_SettingsExt()])
async with Client(server, mode="legacy") as client:
assert client.server_capabilities.extensions is None
def test_duplicate_extension_identifier_raises() -> None:
"""SDK-defined: registering two extensions with the same `identifier` is a
construction error."""
with pytest.raises(ValueError):
MCPServer("test", extensions=[_SettingsExt(), _SettingsExt()])
async def test_extension_method_reachable_via_session_send_request() -> None:
"""SDK-defined: an `Extension` overriding `methods()` wires a new request verb
onto the low-level server, reachable through `client.session.send_request`."""
server = MCPServer("test", extensions=[_MethodExt()])
async with Client(server) as client:
request = _PingRequest(params=_PingParams())
result = await client.session.send_request(request, _PingResult)
assert result == snapshot(_PingResult(pong=True))
async def test_pass_through_interceptor_leaves_tool_result_unchanged() -> None:
"""SDK-defined: an extension whose `intercept_tool_call` delegates to
`call_next` does not alter the underlying tool's `CallToolResult`."""
server = MCPServer("test", extensions=[_PassThroughExt()])
server.tool(name="echo")(_echo)
async with Client(server) as client:
result = await client.call_tool("echo", {"value": "hi"})
assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"}))
async def test_short_circuiting_interceptor_replaces_tool_result() -> None:
"""SDK-defined: an extension that returns from `intercept_tool_call` without
calling `call_next` replaces the tool's result wholesale (the tool never runs)."""
server = MCPServer("test", extensions=[_ReplacingExt()])
server.tool(name="echo", structured_output=False)(_echo)
async with Client(server) as client:
result = await client.call_tool("echo", {"value": "hi"})
assert result == snapshot(CallToolResult(content=[TextContent(text="intercepted")]))
def test_plain_extension_installs_no_tool_call_interceptor() -> None:
"""SDK-defined: an extension that does not override `intercept_tool_call` adds no
middleware - the composed interceptor exists only when at least one extension
overrides it."""
baseline = len(MCPServer("test")._lowlevel_server.middleware)
server = MCPServer("test", extensions=[_AdditiveExt()])
assert len(server._lowlevel_server.middleware) == baseline
def test_overriding_extension_installs_one_tool_call_interceptor() -> None:
"""SDK-defined: an extension that overrides `intercept_tool_call` composes exactly
one additional `tools/call` middleware."""
baseline = len(MCPServer("test")._lowlevel_server.middleware)
server = MCPServer("test", extensions=[_ReplacingExt()])
assert len(server._lowlevel_server.middleware) == baseline + 1
async def test_default_interceptor_passes_through_alongside_an_overriding_one() -> None:
"""SDK-defined: an extension that does not override `intercept_tool_call` runs the
base-class default (pass through) when another extension forces the composed
middleware to exist, leaving the tool result untouched."""
server = MCPServer("test", extensions=[_DefaultExt(), _PassThroughExt()])
server.tool(name="echo")(_echo)
async with Client(server) as client:
result = await client.call_tool("echo", {"value": "hi"})
assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"}))
async def test_interceptors_run_in_registration_order_with_threaded_params() -> None:
"""SDK-defined: `compose_tool_call_interceptor` nests extensions first-outermost, so
two passing-through interceptors record in registration order, each seeing the
validated `tools/call` params (the real tool name)."""
log: list[tuple[str, str]] = []
server = MCPServer(
"test",
extensions=[_RecordingExt("com.example/first", log), _RecordingExt("com.example/second", log)],
)
server.tool(name="echo")(_echo)
async with Client(server) as client:
await client.call_tool("echo", {"value": "hi"})
assert log == [("com.example/first", "echo"), ("com.example/second", "echo")]
async def test_compose_tool_call_interceptor_passes_through_non_tools_call() -> None:
"""SDK-defined: the composed middleware is a no-op for any method other than
`tools/call` - it forwards to `call_next` without touching the interceptors."""
sentinel = types.EmptyResult()
async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult:
return sentinel
middleware = compose_tool_call_interceptor([_ReplacingExt()])
ctx = ServerRequestContext(
session=cast("Any", None),
lifespan_context={},
protocol_version="2026-07-28",
method="tasks/get",
params={"taskId": "t-1"},
)
result = await middleware(ctx, call_next)
assert result is sentinel
def test_extension_subclass_without_prefixed_identifier_is_rejected_at_definition() -> None:
"""SDK-defined: SEP-2133 requires a `vendor-prefix/name` identifier, enforced when the
subclass is defined (a bare name with no prefix is a TypeError)."""
with pytest.raises(TypeError):
type("_BadExt", (Extension,), {"identifier": "noprefix"})
def test_extension_without_identifier_is_rejected_at_registration() -> None:
"""SDK-defined: a subclass that never sets `identifier` (neither class-level nor in
`__init__`) is rejected when the server applies it."""
class _NoIdExt(Extension):
pass
with pytest.raises(TypeError):
MCPServer("test", extensions=[_NoIdExt()])
class _VersionPinnedParams(types.RequestParams):
pass
class _VersionPinnedResult(types.Result):
ok: bool
class _VersionPinnedRequest(types.Request[_VersionPinnedParams, Literal["com.example/pinned"]]):
method: Literal["com.example/pinned"] = "com.example/pinned"
params: _VersionPinnedParams
class _VersionPinnedExt(Extension):
"""A method scoped to 2026-07-28 only via `MethodBinding.protocol_versions`."""
identifier = "com.example/pinned"
def methods(self):
async def handler(ctx: ServerRequestContext[Any, Any], params: _VersionPinnedParams) -> _VersionPinnedResult:
return _VersionPinnedResult(ok=True)
return [MethodBinding("com.example/pinned", _VersionPinnedParams, handler, frozenset({"2026-07-28"}))]
async def test_version_pinned_method_is_served_at_an_allowed_version() -> None:
"""SDK-defined: a `MethodBinding` with `protocol_versions` serves the method at a version
in the set."""
server = MCPServer("test", extensions=[_VersionPinnedExt()])
async with Client(server, mode="2026-07-28") as client:
request = _VersionPinnedRequest(params=_VersionPinnedParams())
result = await client.session.send_request(request, _VersionPinnedResult)
assert result == snapshot(_VersionPinnedResult(ok=True))
async def test_version_pinned_method_is_method_not_found_at_a_disallowed_version() -> None:
"""SDK-defined: the same method at a version outside `protocol_versions` is rejected with
METHOD_NOT_FOUND, mirroring the spec's per-version boundary."""
server = MCPServer("test", extensions=[_VersionPinnedExt()])
async with Client(server, mode="legacy") as client:
request = _VersionPinnedRequest(params=_VersionPinnedParams())
with pytest.raises(MCPError) as exc_info:
await client.session.send_request(request, _VersionPinnedResult)
assert exc_info.value.code == METHOD_NOT_FOUND
assert exc_info.value.error.data == "com.example/pinned"
@pytest.mark.parametrize("method", ["tools/list", "completion/complete"])
def test_method_binding_rejects_spec_methods(method: str) -> None:
"""SDK-defined: extension methods are additive — binding a spec-defined request method
would silently shadow (or be shadowed by) the server's own handler, so it is rejected
when the binding is constructed."""
with pytest.raises(ValueError):
MethodBinding(method, _PingParams, _pong_handler)
def test_method_binding_rejects_empty_protocol_versions() -> None:
"""SDK-defined: an empty `protocol_versions` set would make the method unreachable at
every version; `None` is the universal-version spelling."""
with pytest.raises(ValueError) as exc_info:
MethodBinding("com.example/dead", _PingParams, _pong_handler, frozenset())
assert str(exc_info.value) == snapshot(
"MethodBinding for 'com.example/dead' has an empty protocol_versions set, so it could "
"never be served; use None to admit every version"
)
class _OtherMethodExt(Extension):
"""A second extension binding the same verb as `_MethodExt`."""
identifier = "com.example/other-method"
def methods(self) -> list[MethodBinding]:
return [MethodBinding("com.example/ping", _PingParams, _pong_handler)]
def test_colliding_extension_methods_are_rejected_at_registration() -> None:
"""SDK-defined: two extensions binding the same method would silently last-write-win;
the collision is rejected when the second extension is applied."""
with pytest.raises(ValueError) as exc_info:
MCPServer("test", extensions=[_MethodExt(), _OtherMethodExt()])
assert str(exc_info.value) == snapshot(
"Extension 'com.example/other-method' binds method 'com.example/ping', which is already "
"registered; extension methods are additive and cannot replace another handler"
)
_NEEDS_EXT = "com.example/needed"
class _RequiresExt(Extension):
"""A tool that requires the client to have declared `com.example/needed`."""
identifier = _NEEDS_EXT
def tools(self):
def guarded(ctx: Context) -> str:
require_client_extension(ctx.request_context, _NEEDS_EXT)
return "ok"
return [ToolBinding(fn=guarded)]
async def test_require_client_extension_passes_when_client_declared_it() -> None:
"""SDK-defined: `require_client_extension` is a no-op when the client advertised the id."""
server = MCPServer("test", extensions=[_RequiresExt()])
async with Client(server, extensions=[advertise(_NEEDS_EXT)]) as client:
result = await client.call_tool("guarded", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"}))
async def test_require_client_extension_raises_minus_32021_when_client_did_not_declare_it() -> None:
"""SDK-defined: `require_client_extension` raises the -32021 missing-required-capability
error when the client did not advertise the id."""
server = MCPServer("test", extensions=[_RequiresExt()])
async with Client(server) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("guarded", {})
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
assert exc_info.value.error.data == snapshot({"requiredCapabilities": {"extensions": {_NEEDS_EXT: {}}}})
File diff suppressed because it is too large Load Diff
+349
View File
@@ -0,0 +1,349 @@
"""Integration tests for MCPServer server functionality.
These tests validate the proper functioning of MCPServer features using focused,
single-feature example servers over an in-memory transport.
"""
# TODO(Marcelo): The `examples` package is not being imported as package. We need to solve this.
# pyright: reportUnknownMemberType=false
# pyright: reportMissingImports=false
# pyright: reportUnknownVariableType=false
# pyright: reportUnknownArgumentType=false
import json
import pytest
from inline_snapshot import snapshot
from mcp_types import (
ClientResult,
CreateMessageRequestParams,
CreateMessageResult,
ElicitRequestParams,
ElicitResult,
GetPromptResult,
LoggingMessageNotification,
LoggingMessageNotificationParams,
NotificationParams,
ProgressNotification,
ProgressNotificationParams,
PromptReference,
ReadResourceResult,
ResourceListChangedNotification,
ResourceTemplateReference,
ServerNotification,
ServerRequest,
TextContent,
TextResourceContents,
ToolListChangedNotification,
)
from examples.snippets.servers import (
basic_prompt,
basic_resource,
basic_tool,
completion,
elicitation,
mcpserver_quickstart,
notifications,
sampling,
structured_output,
tool_progress,
)
from mcp.client import Client, ClientRequestContext
from mcp.shared.session import RequestResponder
pytestmark = pytest.mark.anyio
class NotificationCollector:
"""Collects notifications from the server for testing."""
def __init__(self):
self.progress_notifications: list[ProgressNotificationParams] = []
self.log_messages: list[LoggingMessageNotificationParams] = []
self.resource_notifications: list[NotificationParams | None] = []
self.tool_notifications: list[NotificationParams | None] = []
async def handle_generic_notification(
self, message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception
) -> None:
"""Handle any server notification and route to appropriate handler."""
if isinstance(message, ServerNotification): # pragma: no branch
if isinstance(message, ProgressNotification):
self.progress_notifications.append(message.params)
elif isinstance(message, LoggingMessageNotification):
self.log_messages.append(message.params)
elif isinstance(message, ResourceListChangedNotification):
self.resource_notifications.append(message.params)
elif isinstance(message, ToolListChangedNotification): # pragma: no cover
self.tool_notifications.append(message.params)
async def sampling_callback(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult:
"""Sampling callback for tests."""
return CreateMessageResult(
role="assistant",
content=TextContent(
type="text",
text="This is a simulated LLM response for testing",
),
model="test-model",
)
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
"""Elicitation callback for tests."""
# For restaurant booking test
if "No tables available" in params.message:
return ElicitResult(
action="accept",
content={"checkAlternative": True, "alternativeDate": "2024-12-26"},
)
else: # pragma: no cover
return ElicitResult(action="decline")
async def test_basic_tools() -> None:
"""Test basic tool functionality."""
async with Client(basic_tool.mcp) as client:
assert client.server_capabilities.tools is not None
# Test sum tool
tool_result = await client.call_tool("sum", {"a": 5, "b": 3})
assert len(tool_result.content) == 1
assert isinstance(tool_result.content[0], TextContent)
assert tool_result.content[0].text == "8"
# Test weather tool
weather_result = await client.call_tool("get_weather", {"city": "London"})
assert len(weather_result.content) == 1
assert isinstance(weather_result.content[0], TextContent)
assert "Weather in London: 22degreesC" in weather_result.content[0].text
async def test_basic_resources() -> None:
"""Test basic resource functionality."""
async with Client(basic_resource.mcp) as client:
assert client.server_capabilities.resources is not None
# Test document resource
doc_content = await client.read_resource("file://documents/readme")
assert isinstance(doc_content, ReadResourceResult)
assert len(doc_content.contents) == 1
assert isinstance(doc_content.contents[0], TextResourceContents)
assert "Content of readme" in doc_content.contents[0].text
# Test settings resource
settings_content = await client.read_resource("config://settings")
assert isinstance(settings_content, ReadResourceResult)
assert len(settings_content.contents) == 1
assert isinstance(settings_content.contents[0], TextResourceContents)
settings_json = json.loads(settings_content.contents[0].text)
assert settings_json["theme"] == "dark"
assert settings_json["language"] == "en"
async def test_basic_prompts() -> None:
"""Test basic prompt functionality."""
async with Client(basic_prompt.mcp) as client:
assert client.server_capabilities.prompts is not None
# Test review_code prompt
prompts = await client.list_prompts()
review_prompt = next((p for p in prompts.prompts if p.name == "review_code"), None)
assert review_prompt is not None
prompt_result = await client.get_prompt("review_code", {"code": "def hello():\n print('Hello')"})
assert isinstance(prompt_result, GetPromptResult)
assert len(prompt_result.messages) == 1
assert isinstance(prompt_result.messages[0].content, TextContent)
assert "Please review this code:" in prompt_result.messages[0].content.text
assert "def hello():" in prompt_result.messages[0].content.text
# Test debug_error prompt
debug_result = await client.get_prompt(
"debug_error", {"error": "TypeError: 'NoneType' object is not subscriptable"}
)
assert isinstance(debug_result, GetPromptResult)
assert len(debug_result.messages) == 3
assert debug_result.messages[0].role == "user"
assert isinstance(debug_result.messages[0].content, TextContent)
assert "I'm seeing this error:" in debug_result.messages[0].content.text
assert debug_result.messages[1].role == "user"
assert isinstance(debug_result.messages[1].content, TextContent)
assert "TypeError" in debug_result.messages[1].content.text
assert debug_result.messages[2].role == "assistant"
assert isinstance(debug_result.messages[2].content, TextContent)
assert "I'll help debug that" in debug_result.messages[2].content.text
async def test_tool_progress() -> None:
"""Test tool progress reporting."""
collector = NotificationCollector()
async def message_handler(message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception):
await collector.handle_generic_notification(message)
if isinstance(message, Exception): # pragma: no cover
raise message
async with Client(tool_progress.mcp, message_handler=message_handler, mode="legacy") as client:
# Test progress callback
progress_updates = []
async def progress_callback(progress: float, total: float | None, message: str | None) -> None:
progress_updates.append((progress, total, message))
# Call tool with progress
steps = 3
tool_result = await client.call_tool(
"long_running_task",
{"task_name": "Test Task", "steps": steps},
progress_callback=progress_callback,
)
assert tool_result.content == snapshot([TextContent(text="Task 'Test Task' completed")])
# Verify progress updates
assert len(progress_updates) == steps
for i, (progress, total, message) in enumerate(progress_updates):
expected_progress = (i + 1) / steps
assert abs(progress - expected_progress) < 0.01
assert total == 1.0
assert f"Step {i + 1}/{steps}" in message
# Verify log messages
assert len(collector.log_messages) > 0
async def test_sampling() -> None:
"""Test sampling (LLM interaction) functionality."""
async with Client(sampling.mcp, sampling_callback=sampling_callback, mode="legacy") as client:
assert client.server_capabilities.tools is not None
# Test sampling tool
sampling_result = await client.call_tool("generate_poem", {"topic": "nature"})
assert len(sampling_result.content) == 1
assert isinstance(sampling_result.content[0], TextContent)
assert "This is a simulated LLM response" in sampling_result.content[0].text
async def test_elicitation() -> None:
"""Test elicitation (user interaction) functionality."""
async with Client(elicitation.mcp, elicitation_callback=elicitation_callback, mode="legacy") as client:
# Test booking with unavailable date (triggers elicitation)
booking_result = await client.call_tool(
"book_table",
{
"date": "2024-12-25", # Unavailable date
"time": "19:00",
"party_size": 4,
},
)
assert len(booking_result.content) == 1
assert isinstance(booking_result.content[0], TextContent)
assert "[SUCCESS] Booked for 2024-12-26" in booking_result.content[0].text
# Test booking with available date (no elicitation)
booking_result = await client.call_tool(
"book_table",
{
"date": "2024-12-20", # Available date
"time": "20:00",
"party_size": 2,
},
)
assert len(booking_result.content) == 1
assert isinstance(booking_result.content[0], TextContent)
assert "[SUCCESS] Booked for 2024-12-20 at 20:00" in booking_result.content[0].text
async def test_notifications() -> None:
"""Test notifications and logging functionality."""
collector = NotificationCollector()
async def message_handler(message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception):
await collector.handle_generic_notification(message)
if isinstance(message, Exception): # pragma: no cover
raise message
async with Client(notifications.mcp, message_handler=message_handler, mode="legacy") as client:
# Call tool that generates notifications
tool_result = await client.call_tool("process_data", {"data": "test_data"})
assert len(tool_result.content) == 1
assert isinstance(tool_result.content[0], TextContent)
assert "Processed: test_data" in tool_result.content[0].text
# Verify log messages at different levels
assert len(collector.log_messages) >= 4
log_levels = {msg.level for msg in collector.log_messages}
assert "debug" in log_levels
assert "info" in log_levels
assert "warning" in log_levels
assert "error" in log_levels
# Verify resource list changed notification
assert len(collector.resource_notifications) > 0
async def test_completion() -> None:
"""Test completion (autocomplete) functionality."""
async with Client(completion.mcp) as client:
assert client.server_capabilities.resources is not None
assert client.server_capabilities.prompts is not None
# Test resource completion
completion_result = await client.complete(
ref=ResourceTemplateReference(type="ref/resource", uri="github://repos/{owner}/{repo}"),
argument={"name": "repo", "value": ""},
context_arguments={"owner": "modelcontextprotocol"},
)
assert completion_result is not None
assert hasattr(completion_result, "completion")
assert completion_result.completion is not None
assert len(completion_result.completion.values) == 3
assert "python-sdk" in completion_result.completion.values
assert "typescript-sdk" in completion_result.completion.values
assert "specification" in completion_result.completion.values
# Test prompt completion
completion_result = await client.complete(
ref=PromptReference(type="ref/prompt", name="review_code"),
argument={"name": "language", "value": "py"},
)
assert completion_result is not None
assert hasattr(completion_result, "completion")
assert completion_result.completion is not None
assert "python" in completion_result.completion.values
assert all(lang.startswith("py") for lang in completion_result.completion.values)
async def test_mcpserver_quickstart() -> None:
"""Test MCPServer quickstart example."""
async with Client(mcpserver_quickstart.mcp) as client:
# Test add tool
tool_result = await client.call_tool("add", {"a": 10, "b": 20})
assert len(tool_result.content) == 1
assert isinstance(tool_result.content[0], TextContent)
assert tool_result.content[0].text == "30"
# Test greeting resource directly
resource_result = await client.read_resource("greeting://Alice")
assert len(resource_result.contents) == 1
assert isinstance(resource_result.contents[0], TextResourceContents)
assert resource_result.contents[0].text == "Hello, Alice!"
async def test_structured_output() -> None:
"""Test structured output functionality."""
async with Client(structured_output.mcp) as client:
# Test get_weather tool
weather_result = await client.call_tool("get_weather", {"city": "New York"})
assert len(weather_result.content) == 1
assert isinstance(weather_result.content[0], TextContent)
# Check that the result contains expected weather data
result_text = weather_result.content[0].text
assert "22.5" in result_text # temperature
assert "sunny" in result_text # condition
assert "45" in result_text # humidity
assert "5.2" in result_text # wind_speed
@@ -0,0 +1,30 @@
"""Test that parameter descriptions are properly exposed through list_tools"""
import pytest
from pydantic import Field
from mcp.server.mcpserver import MCPServer
@pytest.mark.anyio
async def test_parameter_descriptions():
mcp = MCPServer("Test Server")
@mcp.tool()
def greet(
name: str = Field(description="The name to greet"),
title: str = Field(description="Optional title", default=""),
) -> str: # pragma: no cover
"""A greeting tool"""
return f"Hello {title} {name}"
tools = await mcp.list_tools()
assert len(tools) == 1
tool = tools[0]
# Check that parameter descriptions are present in the schema
properties = tool.input_schema["properties"]
assert "name" in properties
assert properties["name"]["description"] == "The name to greet"
assert "title" in properties
assert properties["title"]["description"] == "Optional title"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+230
View File
@@ -0,0 +1,230 @@
"""Integration tests for title field functionality."""
import pytest
from mcp_types import Prompt, Resource, ResourceTemplate, Tool, ToolAnnotations
from mcp import Client
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.resources import FunctionResource
from mcp.shared.metadata_utils import get_display_name
@pytest.mark.anyio
async def test_server_name_title_description_version():
"""Test that server title and description are set and retrievable correctly."""
mcp = MCPServer(
name="TestServer",
title="Test Server Title",
description="This is a test server description.",
version="1.0",
)
assert mcp.title == "Test Server Title"
assert mcp.description == "This is a test server description."
assert mcp.version == "1.0"
# Start server and connect client
async with Client(mcp) as client:
assert client.server_info.name == "TestServer"
assert client.server_info.title == "Test Server Title"
assert client.server_info.description == "This is a test server description."
assert client.server_info.version == "1.0"
@pytest.mark.anyio
async def test_tool_title_precedence():
"""Test that tool title precedence works correctly: title > annotations.title > name."""
# Create server with various tool configurations
mcp = MCPServer(name="TitleTestServer")
# Tool with only name
@mcp.tool(description="Basic tool")
def basic_tool(message: str) -> str: # pragma: no cover
return message
# Tool with title
@mcp.tool(description="Tool with title", title="User-Friendly Tool")
def tool_with_title(message: str) -> str: # pragma: no cover
return message
# Tool with annotations.title (when title is not supported on decorator)
# We'll need to add this manually after registration
@mcp.tool(description="Tool with annotations")
def tool_with_annotations(message: str) -> str: # pragma: no cover
return message
# Tool with both title and annotations.title
@mcp.tool(description="Tool with both", title="Primary Title")
def tool_with_both(message: str) -> str: # pragma: no cover
return message
# Start server and connect client
async with Client(mcp) as client:
# List tools
tools_result = await client.list_tools()
tools = {tool.name: tool for tool in tools_result.tools}
# Verify basic tool uses name
assert "basic_tool" in tools
basic = tools["basic_tool"]
# Since we haven't implemented get_display_name yet, we'll check the raw fields
assert basic.title is None
assert basic.name == "basic_tool"
# Verify tool with title
assert "tool_with_title" in tools
titled = tools["tool_with_title"]
assert titled.title == "User-Friendly Tool"
# For now, we'll skip the annotations.title test as it requires modifying
# the tool after registration, which we'll implement later
# Verify tool with both uses title over annotations.title
assert "tool_with_both" in tools
both = tools["tool_with_both"]
assert both.title == "Primary Title"
@pytest.mark.anyio
async def test_prompt_title():
"""Test that prompt titles work correctly."""
mcp = MCPServer(name="PromptTitleServer")
# Prompt with only name
@mcp.prompt(description="Basic prompt")
def basic_prompt(topic: str) -> str: # pragma: no cover
return f"Tell me about {topic}"
# Prompt with title
@mcp.prompt(description="Titled prompt", title="Ask About Topic")
def titled_prompt(topic: str) -> str: # pragma: no cover
return f"Tell me about {topic}"
# Start server and connect client
async with Client(mcp) as client:
# List prompts
prompts_result = await client.list_prompts()
prompts = {prompt.name: prompt for prompt in prompts_result.prompts}
# Verify basic prompt uses name
assert "basic_prompt" in prompts
basic = prompts["basic_prompt"]
assert basic.title is None
assert basic.name == "basic_prompt"
# Verify prompt with title
assert "titled_prompt" in prompts
titled = prompts["titled_prompt"]
assert titled.title == "Ask About Topic"
@pytest.mark.anyio
async def test_resource_title():
"""Test that resource titles work correctly."""
mcp = MCPServer(name="ResourceTitleServer")
# Static resource without title
def get_basic_data() -> str: # pragma: no cover
return "Basic data"
basic_resource = FunctionResource(
uri="resource://basic",
name="basic_resource",
description="Basic resource",
fn=get_basic_data,
)
mcp.add_resource(basic_resource)
# Static resource with title
def get_titled_data() -> str: # pragma: no cover
return "Titled data"
titled_resource = FunctionResource(
uri="resource://titled",
name="titled_resource",
title="User-Friendly Resource",
description="Resource with title",
fn=get_titled_data,
)
mcp.add_resource(titled_resource)
# Dynamic resource without title
@mcp.resource("resource://dynamic/{id}")
def dynamic_resource(id: str) -> str: # pragma: no cover
return f"Data for {id}"
# Dynamic resource with title (when supported)
@mcp.resource("resource://titled-dynamic/{id}", title="Dynamic Data")
def titled_dynamic_resource(id: str) -> str: # pragma: no cover
return f"Data for {id}"
# Start server and connect client
async with Client(mcp) as client:
# List resources
resources_result = await client.list_resources()
resources = {str(res.uri): res for res in resources_result.resources}
# Verify basic resource uses name
assert "resource://basic" in resources
basic = resources["resource://basic"]
assert basic.title is None
assert basic.name == "basic_resource"
# Verify resource with title
assert "resource://titled" in resources
titled = resources["resource://titled"]
assert titled.title == "User-Friendly Resource"
# List resource templates
templates_result = await client.list_resource_templates()
templates = {tpl.uri_template: tpl for tpl in templates_result.resource_templates}
# Verify dynamic resource template
assert "resource://dynamic/{id}" in templates
dynamic = templates["resource://dynamic/{id}"]
assert dynamic.title is None
assert dynamic.name == "dynamic_resource"
# Verify titled dynamic resource template (when supported)
if "resource://titled-dynamic/{id}" in templates: # pragma: no branch
titled_dynamic = templates["resource://titled-dynamic/{id}"]
assert titled_dynamic.title == "Dynamic Data"
@pytest.mark.anyio
async def test_get_display_name_utility():
"""Test the get_display_name utility function."""
# Test tool precedence: title > annotations.title > name
tool_name_only = Tool(name="test_tool", input_schema={})
assert get_display_name(tool_name_only) == "test_tool"
tool_with_title = Tool(name="test_tool", title="Test Tool", input_schema={})
assert get_display_name(tool_with_title) == "Test Tool"
tool_with_annotations = Tool(name="test_tool", input_schema={}, annotations=ToolAnnotations(title="Annotated Tool"))
assert get_display_name(tool_with_annotations) == "Annotated Tool"
tool_with_both = Tool(
name="test_tool", title="Primary Title", input_schema={}, annotations=ToolAnnotations(title="Secondary Title")
)
assert get_display_name(tool_with_both) == "Primary Title"
# Test other types: title > name
resource = Resource(uri="file://test", name="test_res")
assert get_display_name(resource) == "test_res"
resource_with_title = Resource(uri="file://test", name="test_res", title="Test Resource")
assert get_display_name(resource_with_title) == "Test Resource"
prompt = Prompt(name="test_prompt")
assert get_display_name(prompt) == "test_prompt"
prompt_with_title = Prompt(name="test_prompt", title="Test Prompt")
assert get_display_name(prompt_with_title) == "Test Prompt"
template = ResourceTemplate(uri_template="file://{id}", name="test_template")
assert get_display_name(template) == "test_template"
template_with_title = ResourceTemplate(uri_template="file://{id}", name="test_template", title="Test Template")
assert get_display_name(template_with_title) == "Test Template"
+906
View File
@@ -0,0 +1,906 @@
import json
import logging
from dataclasses import dataclass
from typing import Any, TypedDict
import pytest
from mcp_types import CallToolResult, TextContent, ToolAnnotations
from pydantic import BaseModel
from mcp.server.context import LifespanContextT, RequestT
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.server.mcpserver.tools import Tool, ToolManager
from mcp.server.mcpserver.utilities.func_metadata import ArgModelBase, FuncMetadata
class TestAddTools:
def test_basic_function(self):
"""Test registering and running a basic function."""
def sum(a: int, b: int) -> int: # pragma: no cover
"""Add two numbers."""
return a + b
manager = ToolManager()
manager.add_tool(sum)
tool = manager.get_tool("sum")
assert tool is not None
assert tool.name == "sum"
assert tool.description == "Add two numbers."
assert tool.is_async is False
assert tool.parameters["properties"]["a"]["type"] == "integer"
assert tool.parameters["properties"]["b"]["type"] == "integer"
def test_init_with_tools(self, caplog: pytest.LogCaptureFixture):
def sum(a: int, b: int) -> int: # pragma: no cover
return a + b
class AddArguments(ArgModelBase):
a: int
b: int
fn_metadata = FuncMetadata(arg_model=AddArguments)
original_tool = Tool(
name="sum",
title="Add Tool",
description="Add two numbers.",
fn=sum,
fn_metadata=fn_metadata,
is_async=False,
parameters=AddArguments.model_json_schema(),
context_kwarg=None,
annotations=None,
)
manager = ToolManager(tools=[original_tool])
saved_tool = manager.get_tool("sum")
assert saved_tool == original_tool
# warn on duplicate tools
with caplog.at_level(logging.WARNING):
manager = ToolManager(True, tools=[original_tool, original_tool])
assert "Tool already exists: sum" in caplog.text
@pytest.mark.anyio
async def test_async_function(self):
"""Test registering and running an async function."""
async def fetch_data(url: str) -> str: # pragma: no cover
"""Fetch data from URL."""
return f"Data from {url}"
manager = ToolManager()
manager.add_tool(fetch_data)
tool = manager.get_tool("fetch_data")
assert tool is not None
assert tool.name == "fetch_data"
assert tool.description == "Fetch data from URL."
assert tool.is_async is True
assert tool.parameters["properties"]["url"]["type"] == "string"
def test_pydantic_model_function(self):
"""Test registering a function that takes a Pydantic model."""
class UserInput(BaseModel):
name: str
age: int
def create_user(user: UserInput, flag: bool) -> dict[str, Any]: # pragma: no cover
"""Create a new user."""
return {"id": 1, **user.model_dump()}
manager = ToolManager()
manager.add_tool(create_user)
tool = manager.get_tool("create_user")
assert tool is not None
assert tool.name == "create_user"
assert tool.description == "Create a new user."
assert tool.is_async is False
assert "name" in tool.parameters["$defs"]["UserInput"]["properties"]
assert "age" in tool.parameters["$defs"]["UserInput"]["properties"]
assert "flag" in tool.parameters["properties"]
def test_add_callable_object(self):
"""Test registering a callable object."""
class MyTool:
def __init__(self):
self.__name__ = "MyTool"
def __call__(self, x: int) -> int: # pragma: no cover
return x * 2
manager = ToolManager()
tool = manager.add_tool(MyTool())
assert tool.name == "MyTool"
assert tool.is_async is False
assert tool.parameters["properties"]["x"]["type"] == "integer"
@pytest.mark.anyio
async def test_add_async_callable_object(self):
"""Test registering an async callable object."""
class MyAsyncTool:
def __init__(self):
self.__name__ = "MyAsyncTool"
async def __call__(self, x: int) -> int: # pragma: no cover
return x * 2
manager = ToolManager()
tool = manager.add_tool(MyAsyncTool())
assert tool.name == "MyAsyncTool"
assert tool.is_async is True
assert tool.parameters["properties"]["x"]["type"] == "integer"
def test_add_invalid_tool(self):
manager = ToolManager()
with pytest.raises(AttributeError):
manager.add_tool(1) # type: ignore
def test_add_lambda(self):
manager = ToolManager()
tool = manager.add_tool(lambda x: x, name="my_tool") # type: ignore[reportUnknownLambdaType]
assert tool.name == "my_tool"
def test_add_lambda_with_no_name(self):
manager = ToolManager()
with pytest.raises(ValueError, match="You must provide a name for lambda functions"):
manager.add_tool(lambda x: x) # type: ignore[reportUnknownLambdaType]
def test_warn_on_duplicate_tools(self, caplog: pytest.LogCaptureFixture):
"""Test warning on duplicate tools."""
def f(x: int) -> int: # pragma: no cover
return x
manager = ToolManager()
manager.add_tool(f)
with caplog.at_level(logging.WARNING):
manager.add_tool(f)
assert "Tool already exists: f" in caplog.text
def test_disable_warn_on_duplicate_tools(self, caplog: pytest.LogCaptureFixture):
"""Test disabling warning on duplicate tools."""
def f(x: int) -> int: # pragma: no cover
return x
manager = ToolManager()
manager.add_tool(f)
manager.warn_on_duplicate_tools = False
with caplog.at_level(logging.WARNING):
manager.add_tool(f)
assert "Tool already exists: f" not in caplog.text
class TestCallTools:
@pytest.mark.anyio
async def test_call_tool(self):
def sum(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
manager = ToolManager()
manager.add_tool(sum)
result = await manager.call_tool("sum", {"a": 1, "b": 2}, Context())
assert result == 3
@pytest.mark.anyio
async def test_call_async_tool(self):
async def double(n: int) -> int:
"""Double a number."""
return n * 2
manager = ToolManager()
manager.add_tool(double)
result = await manager.call_tool("double", {"n": 5}, Context())
assert result == 10
@pytest.mark.anyio
async def test_call_object_tool(self):
class MyTool:
def __init__(self):
self.__name__ = "MyTool"
def __call__(self, x: int) -> int:
return x * 2
manager = ToolManager()
tool = manager.add_tool(MyTool())
result = await tool.run({"x": 5}, Context())
assert result == 10
@pytest.mark.anyio
async def test_call_async_object_tool(self):
class MyAsyncTool:
def __init__(self):
self.__name__ = "MyAsyncTool"
async def __call__(self, x: int) -> int:
return x * 2
manager = ToolManager()
tool = manager.add_tool(MyAsyncTool())
result = await tool.run({"x": 5}, Context())
assert result == 10
@pytest.mark.anyio
async def test_call_tool_with_default_args(self):
def sum(a: int, b: int = 1) -> int:
"""Add two numbers."""
return a + b
manager = ToolManager()
manager.add_tool(sum)
result = await manager.call_tool("sum", {"a": 1}, Context())
assert result == 2
@pytest.mark.anyio
async def test_call_tool_with_missing_args(self):
def sum(a: int, b: int) -> int: # pragma: no cover
"""Add two numbers."""
return a + b
manager = ToolManager()
manager.add_tool(sum)
with pytest.raises(ToolError):
await manager.call_tool("sum", {"a": 1}, Context())
@pytest.mark.anyio
async def test_call_unknown_tool(self):
manager = ToolManager()
with pytest.raises(ToolError):
await manager.call_tool("unknown", {"a": 1}, Context())
@pytest.mark.anyio
async def test_call_tool_with_list_int_input(self):
def sum_vals(vals: list[int]) -> int:
return sum(vals)
manager = ToolManager()
manager.add_tool(sum_vals)
# Try both with plain list and with JSON list
result = await manager.call_tool("sum_vals", {"vals": "[1, 2, 3]"}, Context())
assert result == 6
result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]}, Context())
assert result == 6
@pytest.mark.anyio
async def test_call_tool_with_list_str_or_str_input(self):
def concat_strs(vals: list[str] | str) -> str:
return vals if isinstance(vals, str) else "".join(vals)
manager = ToolManager()
manager.add_tool(concat_strs)
# Try both with plain python object and with JSON list
result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]}, Context())
assert result == "abc"
result = await manager.call_tool("concat_strs", {"vals": '["a", "b", "c"]'}, Context())
assert result == "abc"
result = await manager.call_tool("concat_strs", {"vals": "a"}, Context())
assert result == "a"
result = await manager.call_tool("concat_strs", {"vals": '"a"'}, Context())
assert result == '"a"'
@pytest.mark.anyio
async def test_call_tool_with_complex_model(self):
class MyShrimpTank(BaseModel):
class Shrimp(BaseModel):
name: str
shrimp: list[Shrimp]
x: None
def name_shrimp(tank: MyShrimpTank) -> list[str]:
return [x.name for x in tank.shrimp]
manager = ToolManager()
manager.add_tool(name_shrimp)
result = await manager.call_tool(
"name_shrimp",
{"tank": {"x": None, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}},
Context(),
)
assert result == ["rex", "gertrude"]
result = await manager.call_tool(
"name_shrimp",
{"tank": '{"x": null, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}'},
Context(),
)
assert result == ["rex", "gertrude"]
class TestToolSchema:
@pytest.mark.anyio
async def test_context_arg_excluded_from_schema(self):
def something(a: int, ctx: Context) -> int: # pragma: no cover
return a
manager = ToolManager()
tool = manager.add_tool(something)
assert "ctx" not in json.dumps(tool.parameters)
assert "Context" not in json.dumps(tool.parameters)
assert "ctx" not in tool.fn_metadata.arg_model.model_fields
class TestContextHandling:
"""Test context handling in the tool manager."""
def test_context_parameter_detection(self):
"""Test that context parameters are properly detected in
Tool.from_function()."""
def tool_with_context(x: int, ctx: Context) -> str: # pragma: no cover
return str(x)
manager = ToolManager()
tool = manager.add_tool(tool_with_context)
assert tool.context_kwarg == "ctx"
def tool_without_context(x: int) -> str: # pragma: no cover
return str(x)
tool = manager.add_tool(tool_without_context)
assert tool.context_kwarg is None
def tool_with_parametrized_context(x: int, ctx: Context[LifespanContextT, RequestT]) -> str: # pragma: no cover
return str(x)
tool = manager.add_tool(tool_with_parametrized_context)
assert tool.context_kwarg == "ctx"
@pytest.mark.anyio
async def test_context_injection(self):
"""Test that context is properly injected during tool execution."""
def tool_with_context(x: int, ctx: Context) -> str:
assert isinstance(ctx, Context)
return str(x)
manager = ToolManager()
manager.add_tool(tool_with_context)
result = await manager.call_tool("tool_with_context", {"x": 42}, context=Context())
assert result == "42"
@pytest.mark.anyio
async def test_context_injection_async(self):
"""Test that context is properly injected in async tools."""
async def async_tool(x: int, ctx: Context) -> str:
assert isinstance(ctx, Context)
return str(x)
manager = ToolManager()
manager.add_tool(async_tool)
result = await manager.call_tool("async_tool", {"x": 42}, context=Context())
assert result == "42"
@pytest.mark.anyio
async def test_context_error_handling(self):
"""Test error handling when context injection fails."""
def tool_with_context(x: int, ctx: Context) -> str:
raise ValueError("Test error")
manager = ToolManager()
manager.add_tool(tool_with_context)
with pytest.raises(ToolError, match="Error executing tool tool_with_context"):
await manager.call_tool("tool_with_context", {"x": 42}, context=Context())
class TestToolAnnotations:
def test_tool_annotations(self):
"""Test that tool annotations are correctly added to tools."""
def read_data(path: str) -> str: # pragma: no cover
"""Read data from a file."""
return f"Data from {path}"
annotations = ToolAnnotations(
title="File Reader",
read_only_hint=True,
open_world_hint=False,
)
manager = ToolManager()
tool = manager.add_tool(read_data, annotations=annotations)
assert tool.annotations is not None
assert tool.annotations.title == "File Reader"
assert tool.annotations.read_only_hint is True
assert tool.annotations.open_world_hint is False
@pytest.mark.anyio
async def test_tool_annotations_in_mcpserver(self):
"""Test that tool annotations are included in MCPTool conversion."""
app = MCPServer()
@app.tool(annotations=ToolAnnotations(title="Echo Tool", read_only_hint=True))
def echo(message: str) -> str: # pragma: no cover
"""Echo a message back."""
return message
tools = await app.list_tools()
assert len(tools) == 1
assert tools[0].annotations is not None
assert tools[0].annotations.title == "Echo Tool"
assert tools[0].annotations.read_only_hint is True
class TestStructuredOutput:
"""Test structured output functionality in tools."""
@pytest.mark.anyio
async def test_tool_with_basemodel_output(self):
"""Test tool with BaseModel return type."""
class UserOutput(BaseModel):
name: str
age: int
def get_user(user_id: int) -> UserOutput:
"""Get user by ID."""
return UserOutput(name="John", age=30)
manager = ToolManager()
manager.add_tool(get_user)
result = await manager.call_tool("get_user", {"user_id": 1}, Context(), convert_result=True)
# don't test unstructured output here, just the structured conversion
assert isinstance(result, CallToolResult)
assert result.structured_content == {"name": "John", "age": 30}
@pytest.mark.anyio
async def test_tool_with_primitive_output(self):
"""Test tool with primitive return type."""
def double_number(n: int) -> int:
"""Double a number."""
return 10
manager = ToolManager()
manager.add_tool(double_number)
result = await manager.call_tool("double_number", {"n": 5}, Context())
assert result == 10
result = await manager.call_tool("double_number", {"n": 5}, Context(), convert_result=True)
assert isinstance(result, CallToolResult)
assert isinstance(result.content[0], TextContent) and result.structured_content == {"result": 10}
@pytest.mark.anyio
async def test_tool_with_typeddict_output(self):
"""Test tool with TypedDict return type."""
class UserDict(TypedDict):
name: str
age: int
expected_output = {"name": "Alice", "age": 25}
def get_user_dict(user_id: int) -> UserDict:
"""Get user as dict."""
return UserDict(name="Alice", age=25)
manager = ToolManager()
manager.add_tool(get_user_dict)
result = await manager.call_tool("get_user_dict", {"user_id": 1}, Context())
assert result == expected_output
@pytest.mark.anyio
async def test_tool_with_dataclass_output(self):
"""Test tool with dataclass return type."""
@dataclass
class Person:
name: str
age: int
expected_output = {"name": "Bob", "age": 40}
def get_person() -> Person:
"""Get a person."""
return Person("Bob", 40)
manager = ToolManager()
manager.add_tool(get_person)
result = await manager.call_tool("get_person", {}, Context(), convert_result=True)
# don't test unstructured output here, just the structured conversion
assert isinstance(result, CallToolResult)
assert result.structured_content == expected_output
@pytest.mark.anyio
async def test_tool_with_list_output(self):
"""Test tool with list return type."""
expected_list = [1, 2, 3, 4, 5]
expected_output = {"result": expected_list}
def get_numbers() -> list[int]:
"""Get a list of numbers."""
return expected_list
manager = ToolManager()
manager.add_tool(get_numbers)
result = await manager.call_tool("get_numbers", {}, Context())
assert result == expected_list
result = await manager.call_tool("get_numbers", {}, Context(), convert_result=True)
assert isinstance(result, CallToolResult)
assert isinstance(result.content[0], TextContent) and result.structured_content == expected_output
@pytest.mark.anyio
async def test_tool_without_structured_output(self):
"""Test that tools work normally when structured_output=False."""
def get_dict() -> dict[str, Any]:
"""Get a dict."""
return {"key": "value"}
manager = ToolManager()
manager.add_tool(get_dict, structured_output=False)
result = await manager.call_tool("get_dict", {}, Context())
assert isinstance(result, dict)
assert result == {"key": "value"}
def test_tool_output_schema_property(self):
"""Test that Tool.output_schema property works correctly."""
class UserOutput(BaseModel):
name: str
age: int
def get_user() -> UserOutput: # pragma: no cover
return UserOutput(name="Test", age=25)
manager = ToolManager()
tool = manager.add_tool(get_user)
# Test that output_schema is populated
expected_schema = {
"properties": {"name": {"type": "string", "title": "Name"}, "age": {"type": "integer", "title": "Age"}},
"required": ["name", "age"],
"title": "UserOutput",
"type": "object",
}
assert tool.output_schema == expected_schema
@pytest.mark.anyio
async def test_tool_with_dict_str_any_output(self):
"""Test tool with dict[str, Any] return type."""
def get_config() -> dict[str, Any]:
"""Get configuration"""
return {"debug": True, "port": 8080, "features": ["auth", "logging"]}
manager = ToolManager()
tool = manager.add_tool(get_config)
# Check output schema
assert tool.output_schema is not None
assert tool.output_schema["type"] == "object"
assert "properties" not in tool.output_schema # dict[str, Any] has no constraints
# Test raw result
result = await manager.call_tool("get_config", {}, Context())
expected = {"debug": True, "port": 8080, "features": ["auth", "logging"]}
assert result == expected
# Test converted result
result = await manager.call_tool("get_config", {}, Context())
assert result == expected
@pytest.mark.anyio
async def test_tool_with_dict_str_typed_output(self):
"""Test tool with dict[str, T] return type for specific T."""
def get_scores() -> dict[str, int]:
"""Get player scores"""
return {"alice": 100, "bob": 85, "charlie": 92}
manager = ToolManager()
tool = manager.add_tool(get_scores)
# Check output schema
assert tool.output_schema is not None
assert tool.output_schema["type"] == "object"
assert tool.output_schema["additionalProperties"]["type"] == "integer"
# Test raw result
result = await manager.call_tool("get_scores", {}, Context())
expected = {"alice": 100, "bob": 85, "charlie": 92}
assert result == expected
# Test converted result
result = await manager.call_tool("get_scores", {}, Context())
assert result == expected
class TestToolMetadata:
"""Test tool metadata functionality."""
def test_add_tool_with_metadata(self):
"""Test adding a tool with metadata via ToolManager."""
def process_data(input_data: str) -> str: # pragma: no cover
"""Process some data."""
return f"Processed: {input_data}"
metadata = {"ui": {"type": "form", "fields": ["input"]}, "version": "1.0"}
manager = ToolManager()
tool = manager.add_tool(process_data, meta=metadata)
assert tool.meta is not None
assert tool.meta == metadata
assert tool.meta["ui"]["type"] == "form"
assert tool.meta["version"] == "1.0"
def test_add_tool_without_metadata(self):
"""Test that tools without metadata have None as meta value."""
def simple_tool(x: int) -> int: # pragma: no cover
"""Simple tool."""
return x * 2
manager = ToolManager()
tool = manager.add_tool(simple_tool)
assert tool.meta is None
@pytest.mark.anyio
async def test_metadata_in_mcpserver_decorator(self):
"""Test that metadata is correctly added via MCPServer.tool decorator."""
app = MCPServer()
metadata = {"client": {"ui_component": "file_picker"}, "priority": "high"}
@app.tool(meta=metadata)
def upload_file(filename: str) -> str: # pragma: no cover
"""Upload a file."""
return f"Uploaded: {filename}"
# Get the tool from the tool manager
tool = app._tool_manager.get_tool("upload_file")
assert tool is not None
assert tool.meta is not None
assert tool.meta == metadata
assert tool.meta["client"]["ui_component"] == "file_picker"
assert tool.meta["priority"] == "high"
@pytest.mark.anyio
async def test_metadata_in_list_tools(self):
"""Test that metadata is included in MCPTool when listing tools."""
app = MCPServer()
metadata = {
"ui": {"input_type": "textarea", "rows": 5},
"tags": ["text", "processing"],
}
@app.tool(meta=metadata)
def analyze_text(text: str) -> dict[str, Any]: # pragma: no cover
"""Analyze text content."""
return {"length": len(text), "words": len(text.split())}
tools = await app.list_tools()
assert len(tools) == 1
assert tools[0].meta is not None
assert tools[0].meta == metadata
@pytest.mark.anyio
async def test_multiple_tools_with_different_metadata(self):
"""Test multiple tools with different metadata values."""
app = MCPServer()
metadata1 = {"ui": "form", "version": 1}
metadata2 = {"ui": "picker", "experimental": True}
@app.tool(meta=metadata1)
def tool1(x: int) -> int: # pragma: no cover
"""First tool."""
return x
@app.tool(meta=metadata2)
def tool2(y: str) -> str: # pragma: no cover
"""Second tool."""
return y
@app.tool()
def tool3(z: bool) -> bool: # pragma: no cover
"""Third tool without metadata."""
return z
tools = await app.list_tools()
assert len(tools) == 3
# Find tools by name and check metadata
tools_by_name = {t.name: t for t in tools}
assert tools_by_name["tool1"].meta == metadata1
assert tools_by_name["tool2"].meta == metadata2
assert tools_by_name["tool3"].meta is None
def test_metadata_with_complex_structure(self):
"""Test metadata with complex nested structures."""
def complex_tool(data: str) -> str: # pragma: no cover
"""Tool with complex metadata."""
return data
metadata = {
"ui": {
"components": [
{"type": "input", "name": "field1", "validation": {"required": True, "minLength": 5}},
{"type": "select", "name": "field2", "options": ["a", "b", "c"]},
],
"layout": {"columns": 2, "responsive": True},
},
"permissions": ["read", "write"],
"tags": ["data-processing", "user-input"],
"version": 2,
}
manager = ToolManager()
tool = manager.add_tool(complex_tool, meta=metadata)
assert tool.meta is not None
assert tool.meta["ui"]["components"][0]["validation"]["minLength"] == 5
assert tool.meta["ui"]["layout"]["columns"] == 2
assert "read" in tool.meta["permissions"]
assert "data-processing" in tool.meta["tags"]
def test_metadata_empty_dict(self):
"""Test that empty dict metadata is preserved."""
def tool_with_empty_meta(x: int) -> int: # pragma: no cover
"""Tool with empty metadata."""
return x
manager = ToolManager()
tool = manager.add_tool(tool_with_empty_meta, meta={})
assert tool.meta is not None
assert tool.meta == {}
@pytest.mark.anyio
async def test_metadata_with_annotations(self):
"""Test that metadata and annotations can coexist."""
app = MCPServer()
metadata = {"custom": "value"}
annotations = ToolAnnotations(title="Combined Tool", read_only_hint=True)
@app.tool(meta=metadata, annotations=annotations)
def combined_tool(data: str) -> str: # pragma: no cover
"""Tool with both metadata and annotations."""
return data
tools = await app.list_tools()
assert len(tools) == 1
assert tools[0].meta == metadata
assert tools[0].annotations is not None
assert tools[0].annotations.title == "Combined Tool"
assert tools[0].annotations.read_only_hint is True
class TestRemoveTools:
"""Test tool removal functionality in the tool manager."""
def test_remove_existing_tool(self):
"""Test removing an existing tool."""
def add(a: int, b: int) -> int: # pragma: no cover
"""Add two numbers."""
return a + b
manager = ToolManager()
manager.add_tool(add)
# Verify tool exists
assert manager.get_tool("add") is not None
assert len(manager.list_tools()) == 1
# Remove the tool - should not raise any exception
manager.remove_tool("add")
# Verify tool is removed
assert manager.get_tool("add") is None
assert len(manager.list_tools()) == 0
def test_remove_nonexistent_tool(self):
"""Test removing a non-existent tool raises ToolError."""
manager = ToolManager()
with pytest.raises(ToolError, match="Unknown tool: nonexistent"):
manager.remove_tool("nonexistent")
def test_remove_tool_from_multiple_tools(self):
"""Test removing one tool when multiple tools exist."""
def add(a: int, b: int) -> int: # pragma: no cover
"""Add two numbers."""
return a + b
def multiply(a: int, b: int) -> int: # pragma: no cover
"""Multiply two numbers."""
return a * b
def divide(a: int, b: int) -> float: # pragma: no cover
"""Divide two numbers."""
return a / b
manager = ToolManager()
manager.add_tool(add)
manager.add_tool(multiply)
manager.add_tool(divide)
# Verify all tools exist
assert len(manager.list_tools()) == 3
assert manager.get_tool("add") is not None
assert manager.get_tool("multiply") is not None
assert manager.get_tool("divide") is not None
# Remove middle tool
manager.remove_tool("multiply")
# Verify only multiply is removed
assert len(manager.list_tools()) == 2
assert manager.get_tool("add") is not None
assert manager.get_tool("multiply") is None
assert manager.get_tool("divide") is not None
@pytest.mark.anyio
async def test_call_removed_tool_raises_error(self):
"""Test that calling a removed tool raises ToolError."""
def greet(name: str) -> str:
"""Greet someone."""
return f"Hello, {name}!"
manager = ToolManager()
manager.add_tool(greet)
# Verify tool works before removal
result = await manager.call_tool("greet", {"name": "World"}, Context())
assert result == "Hello, World!"
# Remove the tool
manager.remove_tool("greet")
# Verify calling removed tool raises error
with pytest.raises(ToolError, match="Unknown tool: greet"):
await manager.call_tool("greet", {"name": "World"}, Context())
def test_remove_tool_case_sensitive(self):
"""Test that tool removal is case-sensitive."""
def test_func() -> str: # pragma: no cover
"""Test function."""
return "test"
manager = ToolManager()
manager.add_tool(test_func)
# Verify tool exists
assert manager.get_tool("test_func") is not None
# Try to remove with different case - should raise ToolError
with pytest.raises(ToolError, match="Unknown tool: Test_Func"):
manager.remove_tool("Test_Func")
# Verify original tool still exists
assert manager.get_tool("test_func") is not None
# Remove with correct case
manager.remove_tool("test_func")
assert manager.get_tool("test_func") is None
@@ -0,0 +1,342 @@
"""Test URL mode elicitation feature (SEP 1036)."""
import anyio
import mcp_types as types
import pytest
from mcp_types import ElicitRequestParams, ElicitResult, TextContent
from pydantic import BaseModel, Field
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.server.elicitation import CancelledElicitation, DeclinedElicitation, elicit_url
from mcp.server.mcpserver import Context, MCPServer
@pytest.mark.anyio
async def test_url_elicitation_accept():
"""Test URL mode elicitation with user acceptance."""
mcp = MCPServer(name="URLElicitationServer")
@mcp.tool(description="A tool that uses URL elicitation")
async def request_api_key(ctx: Context) -> str:
result = await ctx.session.elicit_url(
message="Please provide your API key to continue.",
url="https://example.com/api_key_setup",
elicitation_id="test-elicitation-001",
)
# Test only checks accept path
return f"User {result.action}"
# Create elicitation callback that accepts URL mode
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
assert params.mode == "url"
assert params.url == "https://example.com/api_key_setup"
assert params.elicitation_id == "test-elicitation-001"
assert params.message == "Please provide your API key to continue."
return ElicitResult(action="accept")
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("request_api_key", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "User accept"
@pytest.mark.anyio
async def test_url_elicitation_decline():
"""Test URL mode elicitation with user declining."""
mcp = MCPServer(name="URLElicitationDeclineServer")
@mcp.tool(description="A tool that uses URL elicitation")
async def oauth_flow(ctx: Context) -> str:
result = await ctx.session.elicit_url(
message="Authorize access to your files.",
url="https://example.com/oauth/authorize",
elicitation_id="oauth-001",
)
# Test only checks decline path
return f"User {result.action} authorization"
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
assert params.mode == "url"
return ElicitResult(action="decline")
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("oauth_flow", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "User decline authorization"
@pytest.mark.anyio
async def test_url_elicitation_cancel():
"""Test URL mode elicitation with user cancelling."""
mcp = MCPServer(name="URLElicitationCancelServer")
@mcp.tool(description="A tool that uses URL elicitation")
async def payment_flow(ctx: Context) -> str:
result = await ctx.session.elicit_url(
message="Complete payment to proceed.",
url="https://example.com/payment",
elicitation_id="payment-001",
)
# Test only checks cancel path
return f"User {result.action} payment"
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
assert params.mode == "url"
return ElicitResult(action="cancel")
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("payment_flow", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "User cancel payment"
@pytest.mark.anyio
async def test_url_elicitation_helper_function():
"""Test the elicit_url helper function."""
mcp = MCPServer(name="URLElicitationHelperServer")
@mcp.tool(description="Tool using elicit_url helper")
async def setup_credentials(ctx: Context) -> str:
result = await elicit_url(
session=ctx.session,
message="Set up your credentials",
url="https://example.com/setup",
elicitation_id="setup-001",
)
# Test only checks accept path - return the type name
return type(result).__name__
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(action="accept")
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("setup_credentials", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "AcceptedUrlElicitation"
@pytest.mark.anyio
async def test_url_no_content_in_response():
"""Test that URL mode elicitation responses don't include content field."""
mcp = MCPServer(name="URLContentCheckServer")
@mcp.tool(description="Check URL response format")
async def check_url_response(ctx: Context) -> str:
result = await ctx.session.elicit_url(
message="Test message",
url="https://example.com/test",
elicitation_id="test-001",
)
# URL mode responses should not have content
assert result.content is None
return f"Action: {result.action}, Content: {result.content}"
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
# Verify that this is URL mode
assert params.mode == "url"
assert isinstance(params, types.ElicitRequestURLParams)
# URL params have url and elicitation_id, not requested_schema
assert params.url == "https://example.com/test"
assert params.elicitation_id == "test-001"
# Return without content - this is correct for URL mode
return ElicitResult(action="accept")
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("check_url_response", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert "Content: None" in result.content[0].text
@pytest.mark.anyio
async def test_form_mode_still_works():
"""Ensure form mode elicitation still works after SEP 1036."""
mcp = MCPServer(name="FormModeBackwardCompatServer")
class NameSchema(BaseModel):
name: str = Field(description="Your name")
@mcp.tool(description="Test form mode")
async def ask_name(ctx: Context) -> str:
result = await ctx.elicit(message="What is your name?", schema=NameSchema)
# Test only checks accept path with data
assert result.action == "accept"
assert result.data is not None
return f"Hello, {result.data.name}!"
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
# Verify form mode parameters
assert params.mode == "form"
assert isinstance(params, types.ElicitRequestFormParams)
# Form params have requested_schema, not url/elicitation_id
assert params.requested_schema is not None
return ElicitResult(action="accept", content={"name": "Alice"})
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("ask_name", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Hello, Alice!"
@pytest.mark.anyio
async def test_elicit_complete_notification():
"""Test that elicitation completion notifications can be sent and received."""
mcp = MCPServer(name="ElicitCompleteServer")
# Track if the notification was sent
notification_sent = False
@mcp.tool(description="Tool that sends completion notification")
async def trigger_elicitation(ctx: Context) -> str:
nonlocal notification_sent
# Simulate an async operation (e.g., user completing auth in browser)
elicitation_id = "complete-test-001"
# Send completion notification
await ctx.session.send_elicit_complete(elicitation_id)
notification_sent = True
return "Elicitation completed"
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(action="accept") # pragma: no cover
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("trigger_elicitation", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Elicitation completed"
# Give time for notification to be processed
await anyio.sleep(0.1)
# Verify the notification was sent
assert notification_sent
@pytest.mark.anyio
async def test_url_elicitation_required_error_code():
"""Test that the URL_ELICITATION_REQUIRED error code is correct."""
# Verify the error code matches the specification (SEP 1036)
assert types.URL_ELICITATION_REQUIRED == -32042, (
"URL_ELICITATION_REQUIRED error code must be -32042 per SEP 1036 specification"
)
@pytest.mark.anyio
async def test_elicit_url_typed_results():
"""Test that elicit_url returns properly typed result objects."""
mcp = MCPServer(name="TypedResultsServer")
@mcp.tool(description="Test declined result")
async def test_decline(ctx: Context) -> str:
result = await elicit_url(
session=ctx.session,
message="Test decline",
url="https://example.com/decline",
elicitation_id="decline-001",
)
if isinstance(result, DeclinedElicitation):
return "Declined"
return "Not declined" # pragma: no cover
@mcp.tool(description="Test cancelled result")
async def test_cancel(ctx: Context) -> str:
result = await elicit_url(
session=ctx.session,
message="Test cancel",
url="https://example.com/cancel",
elicitation_id="cancel-001",
)
if isinstance(result, CancelledElicitation):
return "Cancelled"
return "Not cancelled" # pragma: no cover
# Test declined result
async def decline_callback(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(action="decline")
async with Client(mcp, mode="legacy", elicitation_callback=decline_callback) as client:
result = await client.call_tool("test_decline", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Declined"
# Test cancelled result
async def cancel_callback(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(action="cancel")
async with Client(mcp, mode="legacy", elicitation_callback=cancel_callback) as client:
result = await client.call_tool("test_cancel", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Cancelled"
@pytest.mark.anyio
async def test_deprecated_elicit_method():
"""Test the deprecated elicit() method for backward compatibility."""
mcp = MCPServer(name="DeprecatedElicitServer")
class EmailSchema(BaseModel):
email: str = Field(description="Email address")
@mcp.tool(description="Test deprecated elicit method")
async def use_deprecated_elicit(ctx: Context) -> str:
# Use the deprecated elicit() method which should call elicit_form()
result = await ctx.session.elicit(
message="Enter your email",
requested_schema=EmailSchema.model_json_schema(),
)
if result.action == "accept" and result.content:
return f"Email: {result.content.get('email', 'none')}"
return "No email provided" # pragma: no cover
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
# Verify this is form mode
assert params.mode == "form"
assert params.requested_schema is not None
return ElicitResult(action="accept", content={"email": "test@example.com"})
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("use_deprecated_elicit", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Email: test@example.com"
@pytest.mark.anyio
async def test_ctx_elicit_url_convenience_method():
"""Test the ctx.elicit_url() convenience method (vs ctx.session.elicit_url())."""
mcp = MCPServer(name="CtxElicitUrlServer")
@mcp.tool(description="A tool that uses ctx.elicit_url() directly")
async def direct_elicit_url(ctx: Context) -> str:
# Use ctx.elicit_url() directly instead of ctx.session.elicit_url()
result = await ctx.elicit_url(
message="Test the convenience method",
url="https://example.com/test",
elicitation_id="ctx-test-001",
)
return f"Result: {result.action}"
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
assert params.mode == "url"
assert params.elicitation_id == "ctx-test-001"
return ElicitResult(action="accept")
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("direct_elicit_url", {})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Result: accept"
@@ -0,0 +1,109 @@
"""Test that UrlElicitationRequiredError is properly propagated as MCP error."""
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp import Client, ErrorData
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared.exceptions import MCPError, UrlElicitationRequiredError
@pytest.mark.anyio
async def test_url_elicitation_error_thrown_from_tool():
"""Test that UrlElicitationRequiredError raised from a tool is received as MCPError by client."""
mcp = MCPServer(name="UrlElicitationErrorServer")
@mcp.tool(description="A tool that raises UrlElicitationRequiredError")
async def connect_service(service_name: str, ctx: Context) -> str:
# This tool cannot proceed without authorization
raise UrlElicitationRequiredError(
[
types.ElicitRequestURLParams(
mode="url",
message=f"Authorization required to connect to {service_name}",
url=f"https://{service_name}.example.com/oauth/authorize",
elicitation_id=f"{service_name}-auth-001",
)
]
)
async with Client(mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("connect_service", {"service_name": "github"})
assert exc_info.value.error == snapshot(
ErrorData(
code=types.URL_ELICITATION_REQUIRED,
message="URL elicitation required",
data={
"elicitations": [
{
"mode": "url",
"message": "Authorization required to connect to github",
"url": "https://github.example.com/oauth/authorize",
"elicitationId": "github-auth-001",
}
]
},
)
)
@pytest.mark.anyio
async def test_url_elicitation_error_from_error():
"""Test that client can reconstruct UrlElicitationRequiredError from MCPError."""
mcp = MCPServer(name="UrlElicitationErrorServer")
@mcp.tool(description="A tool that raises UrlElicitationRequiredError with multiple elicitations")
async def multi_auth(ctx: Context) -> str:
raise UrlElicitationRequiredError(
[
types.ElicitRequestURLParams(
mode="url",
message="GitHub authorization required",
url="https://github.example.com/oauth",
elicitation_id="github-auth",
),
types.ElicitRequestURLParams(
mode="url",
message="Google Drive authorization required",
url="https://drive.google.com/oauth",
elicitation_id="gdrive-auth",
),
]
)
async with Client(mcp) as client:
# Call the tool and catch the error
with pytest.raises(MCPError) as exc_info:
await client.call_tool("multi_auth", {})
# Reconstruct the typed error
mcp_error = exc_info.value
assert mcp_error.code == types.URL_ELICITATION_REQUIRED
url_error = UrlElicitationRequiredError.from_error(mcp_error.error)
# Verify the reconstructed error has both elicitations
assert len(url_error.elicitations) == 2
assert url_error.elicitations[0].elicitation_id == "github-auth"
assert url_error.elicitations[1].elicitation_id == "gdrive-auth"
@pytest.mark.anyio
async def test_normal_exceptions_still_return_error_result():
"""Test that normal exceptions still return CallToolResult with is_error=True."""
mcp = MCPServer(name="NormalErrorServer")
@mcp.tool(description="A tool that raises a normal exception")
async def failing_tool(ctx: Context) -> str:
raise ValueError("Something went wrong")
async with Client(mcp) as client:
# Normal exceptions should be returned as error results, not MCPError
result = await client.call_tool("failing_tool", {})
assert result.is_error is True
assert len(result.content) == 1
assert isinstance(result.content[0], types.TextContent)
assert "Something went wrong" in result.content[0].text
+57
View File
@@ -0,0 +1,57 @@
import mcp_types as types
import pytest
from mcp import Client
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.mcpserver.tools.base import Tool
from mcp.shared.exceptions import MCPError
def test_context_detected_in_union_annotation():
def my_tool(x: int, ctx: Context | None) -> str:
raise NotImplementedError
tool = Tool.from_function(my_tool)
assert tool.context_kwarg == "ctx"
@pytest.mark.anyio
async def test_mcperror_raised_from_a_tool_surfaces_as_a_top_level_jsonrpc_error_with_code_and_data_intact():
"""SDK-defined: ``MCPError`` carries JSON-RPC ``ErrorData(code, message, data)``
and means "respond with a protocol error". The tool wrapper re-raises it so
the kernel writes a top-level JSON-RPC error - ``code`` and ``data`` survive
the round-trip rather than being flattened into ``CallToolResult(isError=True)``."""
mcp = MCPServer(name="srv")
@mcp.tool()
async def needs_sampling() -> str:
raise MCPError(
types.MISSING_REQUIRED_CLIENT_CAPABILITY,
"sampling capability required",
data={"requiredCapabilities": ["sampling"]},
)
async with Client(mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("needs_sampling", {})
assert exc_info.value.error.code == types.MISSING_REQUIRED_CLIENT_CAPABILITY
assert exc_info.value.error.data == {"requiredCapabilities": ["sampling"]}
@pytest.mark.anyio
async def test_non_mcperror_exception_raised_from_a_tool_is_wrapped_as_an_is_error_result():
"""SDK-defined: ordinary exceptions from a tool body are execution failures
the LLM should see, so they become ``CallToolResult(isError=True)`` rather
than a protocol-level JSON-RPC error. Pins the other arm of the same branch."""
mcp = MCPServer(name="srv")
@mcp.tool()
async def boom() -> str:
raise RuntimeError("execution failure")
async with Client(mcp) as client:
result = await client.call_tool("boom", {})
assert isinstance(result, types.CallToolResult)
assert result.is_error is True
+304
View File
@@ -0,0 +1,304 @@
"""Tests for the MCP Apps extension (`io.modelcontextprotocol/ui`, SEP-2133).
The headline property is SEP-2133 graceful degradation: a UI-bound tool returns
rich output to a client that negotiated Apps and text-only output to one that did
not. The remaining tests pin SDK-defined wiring (the `_meta.ui.resourceUri` stamp,
the `ui://` resource MIME type, capability advertisement, and `ui://`-scheme
validation).
"""
from typing import Any
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, ReadResourceResult, TextContent, TextResourceContents
from mcp.client import advertise
from mcp.client.client import Client
from mcp.server import Server, ServerRequestContext
from mcp.server.apps import (
APP_MIME_TYPE,
EXTENSION_ID,
Apps,
ResourceCsp,
ResourcePermissions,
client_supports_apps,
)
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.context import Context
from mcp.server.mcpserver.resources import TextResource
pytestmark = pytest.mark.anyio
def _clock_server() -> MCPServer:
apps = Apps()
@apps.tool(resource_uri="ui://clock/app.html", title="Get Time", description="Return the current time.")
def get_time(ctx: Context) -> str:
if not client_supports_apps(ctx):
return "The time is 2026-06-26T00:00:00Z."
return "2026-06-26T00:00:00Z"
apps.add_html_resource("ui://clock/app.html", "<title>Clock</title>", title="Clock")
return MCPServer("clock", extensions=[apps])
async def test_apps_tool_stamps_ui_resource_uri_on_tool_meta() -> None:
"""SDK-defined: `@apps.tool(resource_uri=...)` stamps `_meta.ui.resourceUri` on the
advertised tool, observed end-to-end through `list_tools`."""
async with Client(_clock_server()) as client:
result = await client.list_tools()
assert [(t.name, t.meta) for t in result.tools] == snapshot(
[("get_time", {"ui": {"resourceUri": "ui://clock/app.html"}})]
)
async def test_add_html_resource_serves_ui_resource_at_app_mime_type() -> None:
"""SDK-defined: `add_html_resource` registers the `ui://` resource served as
`text/html;profile=mcp-app`, observed through `read_resource`."""
async with Client(_clock_server()) as client:
result = await client.read_resource("ui://clock/app.html")
assert result == snapshot(
ReadResourceResult(
contents=[
TextResourceContents(
uri="ui://clock/app.html",
mime_type="text/html;profile=mcp-app",
text="<title>Clock</title>",
)
]
)
)
assert isinstance(result.contents[0], TextResourceContents)
assert result.contents[0].mime_type == APP_MIME_TYPE
async def test_auto_mode_carries_apps_extension_under_server_capabilities() -> None:
"""SDK-defined: the Apps extension rides `server/discover`, so a `mode='auto'` client
sees `EXTENSION_ID` under `server_capabilities.extensions`."""
async with Client(_clock_server(), mode="auto") as client:
assert client.server_capabilities.extensions == snapshot({"io.modelcontextprotocol/ui": {}})
async def test_legacy_handshake_drops_apps_extension_from_capabilities() -> None:
"""Pinned gap: the 2025 `ServerCapabilities` wire schema has no `extensions` field,
so a `mode='legacy'` handshake cannot carry the Apps capability -- only `mode='auto'`
(server/discover) does. This pins the divergence rather than fixing it."""
async with Client(_clock_server(), mode="legacy") as client:
assert client.server_capabilities.extensions is None
async def test_apps_tool_returns_rich_output_when_client_negotiated_apps() -> None:
"""SEP-2133 graceful degradation: a client that advertised `EXTENSION_ID` gets the
rich (UI) path, while one that did not gets the text-only fallback. The same tool,
branching on `client_supports_apps(ctx)`, drives both halves."""
server = _clock_server()
async with Client(server, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]) as supports:
rich = await supports.call_tool("get_time", {})
async with Client(server) as plain:
fallback = await plain.call_tool("get_time", {})
assert rich.content == snapshot([TextContent(text="2026-06-26T00:00:00Z")])
assert fallback.content == snapshot([TextContent(text="The time is 2026-06-26T00:00:00Z.")])
async def _observed_client_supports_apps(ui_settings: dict[str, Any] | None) -> bool:
"""Run one probe `tools/call` and report what `client_supports_apps` saw server-side.
Exercises the lowlevel `ServerRequestContext` form, which reads the client's
advertised extensions off `session.client_params`.
"""
observed: list[bool] = []
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "probe"
observed.append(client_supports_apps(ctx))
return CallToolResult(content=[TextContent(text="ok")])
server = Server("probe", on_list_tools=list_tools, on_call_tool=call_tool)
extensions = None if ui_settings is None else [advertise(EXTENSION_ID, ui_settings)]
async with Client(server, extensions=extensions) as client:
await client.call_tool("probe", {})
return observed[0]
@pytest.mark.parametrize(
("ui_settings", "expected"),
[
pytest.param({"mimeTypes": [APP_MIME_TYPE]}, True, id="html-mime-listed"),
pytest.param({"mimeTypes": (APP_MIME_TYPE,)}, True, id="in-process-tuple-mime-types"),
pytest.param(None, False, id="extension-not-declared"),
pytest.param({"mimeTypes": ["application/x-other"]}, False, id="html-mime-not-offered"),
pytest.param({}, False, id="mime-types-key-missing"),
],
)
async def test_client_supports_apps_from_lowlevel_request_context(
ui_settings: dict[str, Any] | None, expected: bool
) -> None:
"""ext-apps: `client_supports_apps` is `True` only when the client declared the ui
extension AND listed `text/html;profile=mcp-app` in its `mimeTypes` settings — a
required field, so its absence means unsupported (the reference SDK's check is
`uiCap?.mimeTypes?.includes(...)`)."""
assert await _observed_client_supports_apps(ui_settings) is expected
def test_apps_tool_rejects_non_ui_resource_uri() -> None:
"""SDK-defined: `@apps.tool` accepts only `ui://` URIs; any other scheme is a
programmer error raised at decoration time."""
apps = Apps()
with pytest.raises(ValueError):
apps.tool(resource_uri="https://example.com/app.html")
def test_add_html_resource_rejects_non_ui_resource_uri() -> None:
"""SDK-defined: `add_html_resource` accepts only `ui://` URIs; any other scheme is
a programmer error raised at registration time."""
apps = Apps()
with pytest.raises(ValueError):
apps.add_html_resource("https://example.com/app.html", "<title>x</title>")
def _widget() -> str:
"""A UI-bound tool body (shared so its one covered call serves both meta tests)."""
return "x"
async def test_apps_tool_stamps_visibility_when_given() -> None:
"""SDK-defined: `@apps.tool(visibility=...)` is stamped into `_meta.ui.visibility`."""
apps = Apps()
apps.tool(resource_uri="ui://v/app.html", visibility=["app"])(_widget)
apps.add_html_resource("ui://v/app.html", "<title>v</title>")
async with Client(MCPServer("v", extensions=[apps])) as client:
result = await client.list_tools()
called = await client.call_tool("_widget", {})
assert result.tools[0].meta == snapshot({"ui": {"resourceUri": "ui://v/app.html", "visibility": ["app"]}})
assert called.content == snapshot([TextContent(text="x")])
async def test_apps_tool_merges_extra_meta_alongside_ui() -> None:
"""SDK-defined: `@apps.tool(meta=...)` merges extra `_meta` keys with the `ui` entry
(previously a `meta=` argument raised a duplicate-keyword TypeError)."""
apps = Apps()
apps.tool(resource_uri="ui://m/app.html", meta={"com.example/k": 1})(_widget)
apps.add_html_resource("ui://m/app.html", "<title>m</title>")
async with Client(MCPServer("m", extensions=[apps])) as client:
result = await client.list_tools()
assert result.tools[0].meta == snapshot({"com.example/k": 1, "ui": {"resourceUri": "ui://m/app.html"}})
async def test_add_html_resource_stamps_csp_and_permissions_on_resource_meta() -> None:
"""SDK-defined: `csp`/`permissions` populate the resource's `_meta.ui` per ext-apps."""
apps = Apps()
apps.add_html_resource(
"ui://r/app.html",
"<title>r</title>",
csp=ResourceCsp(connect_domains=["https://api.example.com"]),
permissions=ResourcePermissions(camera={}),
domain="r.example.com",
prefers_border=True,
)
async with Client(MCPServer("r", extensions=[apps])) as client:
listed = await client.list_resources()
result = await client.read_resource("ui://r/app.html")
expected_ui_meta = snapshot(
{
"ui": {
"csp": {"connectDomains": ["https://api.example.com"]},
"permissions": {"camera": {}},
"domain": "r.example.com",
"prefersBorder": True,
}
}
)
# Hosts read `_meta.ui` from the read content item, with the list entry as
# fallback — the SDK stamps the same value in both places.
assert isinstance(result.contents[0], TextResourceContents)
assert result.contents[0].meta == expected_ui_meta
assert listed.resources[0].meta == expected_ui_meta
def test_apps_tool_with_unregistered_resource_uri_is_rejected_at_construction() -> None:
"""SDK-defined: a tool whose `resource_uri` has no matching registered resource would
advertise a `_meta.ui.resourceUri` that 404s on `resources/read`; the misconfiguration
is rejected when the server consumes the extension."""
apps = Apps()
apps.tool(resource_uri="ui://missing/app.html")(_widget)
with pytest.raises(ValueError) as exc_info:
MCPServer("broken", extensions=[apps])
assert str(exc_info.value) == snapshot(
"Apps tool '_widget' binds resource_uri 'ui://missing/app.html', but no such resource "
"is registered; add it with add_html_resource() or add_resource()"
)
async def test_add_resource_registers_a_prebuilt_ui_resource() -> None:
"""SDK-defined: `add_resource` is the escape hatch for pre-built `ui://` resources
that `add_html_resource` cannot express; it satisfies a tool's `resource_uri` binding."""
apps = Apps()
apps.tool(resource_uri="ui://prebuilt/app.html")(_widget)
apps.add_resource(
TextResource(uri="ui://prebuilt/app.html", name="prebuilt", mime_type=APP_MIME_TYPE, text="<title>p</title>")
)
async with Client(MCPServer("p", extensions=[apps])) as client:
result = await client.read_resource("ui://prebuilt/app.html")
assert isinstance(result.contents[0], TextResourceContents)
assert result.contents[0].text == "<title>p</title>"
def test_add_resource_rejects_non_ui_resource_uri() -> None:
"""SDK-defined: `add_resource` accepts only `ui://` URIs, like the other registrars."""
apps = Apps()
with pytest.raises(ValueError):
apps.add_resource(TextResource(uri="https://example.com/app.html", name="x", text="x"))
def test_apps_tool_rejects_a_ui_meta_key() -> None:
"""SDK-defined: the decorator owns `_meta['ui']` — a caller-supplied `'ui'` entry would be
silently clobbered, so it is rejected at decoration time (use `resource_uri=`/`visibility=`)."""
apps = Apps()
with pytest.raises(ValueError) as exc_info:
apps.tool(resource_uri="ui://c/app.html", meta={"ui": {"resourceUri": "ui://other.html"}})
assert str(exc_info.value) == snapshot(
"Apps.tool() owns _meta['ui']; pass resource_uri=/visibility= instead of a 'ui' meta key"
)
async def test_add_resource_defaults_the_mime_type_to_the_app_mime() -> None:
"""ext-apps: hosts only render `ui://` resources served as `text/html;profile=mcp-app`,
so a resource registered without an explicit `mime_type` gets it by default."""
apps = Apps()
apps.add_resource(TextResource(uri="ui://d/app.html", name="d", text="<title>d</title>"))
async with Client(MCPServer("d", extensions=[apps])) as client:
result = await client.read_resource("ui://d/app.html")
assert isinstance(result.contents[0], TextResourceContents)
assert result.contents[0].mime_type == APP_MIME_TYPE
def test_add_resource_rejects_an_explicit_non_app_mime_type() -> None:
"""ext-apps: an explicit `mime_type` other than `text/html;profile=mcp-app` would make
the resource unrenderable; the mismatch is rejected at registration."""
apps = Apps()
with pytest.raises(ValueError) as exc_info:
apps.add_resource(TextResource(uri="ui://e/app.html", name="e", mime_type="text/html", text="x"))
assert str(exc_info.value) == snapshot(
"MCP Apps resources are served as 'text/html;profile=mcp-app', got 'text/html'"
)
+243
View File
@@ -0,0 +1,243 @@
"""`mcp.server.caching`: `CacheHint` validation, per-field fills, and the
`cache_hints` constructor map reaching the wire on both server tiers."""
from typing import Any, cast
import pytest
from inline_snapshot import snapshot
from mcp_types import (
InputRequiredResult,
ListResourcesResult,
ListToolsResult,
PaginatedRequestParams,
ReadResourceRequestParams,
Resource,
Tool,
)
from mcp import Client
from mcp.server import CacheHint, MCPServer, Server, ServerRequestContext
from mcp.server.caching import apply_cache_hint
pytestmark = pytest.mark.anyio
def test_cache_hint_defaults_match_the_conservative_model_defaults() -> None:
"""SDK-defined: an unconfigured hint fills the same values the result models
already default to - immediately stale, not shared - so stamping it is
indistinguishable from not stamping at all."""
hint = CacheHint()
model = ListToolsResult(tools=[])
assert (hint.ttl_ms, hint.scope) == (model.ttl_ms, model.cache_scope)
def test_a_negative_ttl_is_rejected_at_hint_construction() -> None:
"""Spec-mandated: servers MUST provide `ttlMs >= 0`, so a negative value
fails at `CacheHint` construction rather than reaching the wire."""
with pytest.raises(ValueError) as exc:
CacheHint(ttl_ms=-1)
assert str(exc.value) == snapshot("ttl_ms must be >= 0, got -1")
def test_an_unknown_scope_is_rejected_at_hint_construction() -> None:
"""Spec-mandated: `cacheScope` is a closed enum, enforced for untyped callers
the type checker cannot see."""
with pytest.raises(ValueError) as exc:
CacheHint(scope=cast(Any, "shared"))
assert str(exc.value) == snapshot("scope must be 'public' or 'private', got 'shared'")
def test_apply_cache_hint_fills_only_the_fields_the_handler_left_unset() -> None:
"""SDK-defined precedence, per field: the handler's explicit `ttl_ms` stays,
the unset `cache_scope` takes the hint's value."""
result = ListToolsResult(tools=[], ttl_ms=10)
filled = apply_cache_hint(result, CacheHint(ttl_ms=60_000, scope="public"))
assert filled.ttl_ms == 10
assert filled.cache_scope == "public"
def test_apply_cache_hint_never_overrides_explicit_fields_even_at_default_values() -> None:
"""SDK-defined: an explicit `ttl_ms=0, cache_scope="private"` is a handler
decision, not an absence - the hint must not replace it (`model_fields_set`
distinguishes the two)."""
result = ListToolsResult(tools=[], ttl_ms=0, cache_scope="private")
assert apply_cache_hint(result, CacheHint(ttl_ms=60_000, scope="public")) is result
def test_a_non_cacheable_method_in_cache_hints_is_rejected_at_server_construction() -> None:
"""SDK-defined: only the six cacheable methods take hints; a typo or a
non-cacheable method fails at `Server(...)` time, not silently at runtime."""
with pytest.raises(ValueError) as exc:
Server("srv", cache_hints=cast(Any, {"tools/call": CacheHint()}))
assert str(exc.value) == snapshot(
"cache_hints keys must be cacheable methods (see CacheableMethod); got: 'tools/call'"
)
def test_a_non_cache_hint_value_is_rejected_at_server_construction() -> None:
"""SDK-defined: a config-shaped value (a plain dict instead of a `CacheHint`)
fails at `Server(...)` time too - not with an `AttributeError` on the first
request to that method."""
with pytest.raises(TypeError) as exc:
Server("srv", cache_hints=cast(Any, {"tools/list": {"ttl_ms": 60_000}}))
assert str(exc.value) == snapshot("cache_hints['tools/list'] must be a CacheHint, got dict")
def test_a_non_string_cache_hints_key_is_rejected_with_the_unknown_key_error() -> None:
"""A non-string key takes the same unknown-key ValueError as a typo, not a TypeError from message formatting."""
with pytest.raises(ValueError) as exc:
Server("srv", cache_hints=cast(Any, {42: CacheHint()}))
assert str(exc.value) == snapshot("cache_hints keys must be cacheable methods (see CacheableMethod); got: 42")
async def test_a_dict_returning_handler_takes_the_configured_hint() -> None:
"""The stamp covers raw-dict results too - 2026-07-28 requires both fields on the wire."""
hint = CacheHint(ttl_ms=60_000, scope="public")
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams) -> dict[str, Any]:
return {"tools": [], "resultType": "complete"}
server = Server("srv", cache_hints={"tools/list": hint})
server.add_request_handler("tools/list", PaginatedRequestParams, list_tools)
async with Client(server) as client:
result = await client.list_tools()
assert result.ttl_ms == hint.ttl_ms
assert result.cache_scope == hint.scope
async def test_a_dict_provided_ttl_wins_and_the_hint_fills_only_the_missing_scope() -> None:
"""Dict path mirrors the model path's `model_fields_set` precedence: present wire keys win."""
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams) -> dict[str, Any]:
return {"tools": [], "resultType": "complete", "ttlMs": 25}
server = Server("srv", cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")})
server.add_request_handler("tools/list", PaginatedRequestParams, list_tools)
async with Client(server) as client:
result = await client.list_tools()
assert result.ttl_ms == 25
assert result.cache_scope == "public"
async def test_a_dict_returning_handler_leaks_no_hint_fields_to_a_2025_session() -> None:
"""The stamp runs version-independently; the 2025 serialize sieve strips the fields."""
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams) -> dict[str, Any]:
return {"tools": []}
server = Server("srv", cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")})
server.add_request_handler("tools/list", PaginatedRequestParams, list_tools)
async with Client(server, mode="legacy") as client:
result = await client.list_tools()
assert "ttl_ms" not in result.model_fields_set
assert "cache_scope" not in result.model_fields_set
async def test_an_input_required_shaped_dict_is_never_stamped() -> None:
"""Spec carve-out: interim `input_required` results carry no cache hints, even on a hinted method."""
async def read_resource(ctx: ServerRequestContext[Any], params: ReadResourceRequestParams) -> dict[str, Any]:
return {"resultType": "input_required", "requestState": "s1"}
server = Server("srv", cache_hints={"resources/read": CacheHint(ttl_ms=60_000, scope="public")})
server.add_request_handler("resources/read", ReadResourceRequestParams, read_resource)
async with Client(server) as client:
result = await client.session.read_resource("res://x", allow_input_required=True)
assert isinstance(result, InputRequiredResult)
assert result.model_dump(by_alias=True, exclude_none=True) == snapshot(
{"resultType": "input_required", "requestState": "s1"}
)
async def test_server_cache_hints_reach_the_wire_for_a_bare_handler_result() -> None:
"""SDK-defined: a lowlevel handler that never thinks about caching emits the
server-wide hint configured at construction."""
hint = CacheHint(ttl_ms=60_000, scope="public")
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})])
server = Server("srv", on_list_tools=list_tools, cache_hints={"tools/list": hint})
async with Client(server) as client:
result = await client.list_tools()
assert result.ttl_ms == hint.ttl_ms
assert result.cache_scope == hint.scope
async def test_every_page_of_a_paginated_list_carries_the_configured_scope() -> None:
"""Spec-mandated: the same `cacheScope` MUST apply to all pages of one list.
The map is keyed by method, not cursor, so a handler that leaves scope unset
gets the same scope on every page. (A handler that overrides the scope owns
that consistency itself - see `docs/client/caching.md`.)"""
names = [f"r-{n}" for n in range(4)]
async def list_resources(
ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None
) -> ListResourcesResult:
start = 0 if params is None or params.cursor is None else int(params.cursor)
page = [Resource(uri=f"res://{name}", name=name) for name in names[start : start + 2]]
next_cursor = str(start + 2) if start + 2 < len(names) else None
return ListResourcesResult(resources=page, next_cursor=next_cursor)
server = Server(
"srv",
on_list_resources=list_resources,
cache_hints={"resources/list": CacheHint(ttl_ms=30_000, scope="public")},
)
async with Client(server) as client:
first = await client.list_resources()
assert first.next_cursor is not None
second = await client.list_resources(cursor=first.next_cursor)
assert (first.cache_scope, second.cache_scope) == ("public", "public")
assert (first.ttl_ms, second.ttl_ms) == (30_000, 30_000)
async def test_the_default_discover_handler_takes_the_server_discover_hint() -> None:
"""SDK-defined: the auto-derived `server/discover` result is stamped from the
map like any other cacheable result - no separate discover-specific knob."""
server = Server("srv", cache_hints={"server/discover": CacheHint(ttl_ms=300_000, scope="public")})
async with Client(server) as client:
discovered = await client.session.discover()
assert discovered.ttl_ms == 300_000
assert discovered.cache_scope == "public"
async def test_mcpserver_cache_hints_cover_every_high_level_handler() -> None:
"""SDK-defined: the `MCPServer` constructor map reaches all six cacheable
methods. Each method gets a distinct `ttl_ms` so a failure names the handler
that lost its hint."""
mcp = MCPServer(
"demo",
cache_hints={
"tools/list": CacheHint(ttl_ms=1_000, scope="public"),
"resources/list": CacheHint(ttl_ms=2_000, scope="public"),
"resources/templates/list": CacheHint(ttl_ms=3_000, scope="public"),
"prompts/list": CacheHint(ttl_ms=4_000, scope="public"),
"resources/read": CacheHint(ttl_ms=5_000, scope="public"),
"server/discover": CacheHint(ttl_ms=6_000, scope="public"),
},
)
@mcp.tool()
def add(a: int, b: int) -> int:
raise NotImplementedError
@mcp.resource("config://app")
def config() -> str:
return "cfg"
@mcp.resource("greeting://{name}")
def greeting(name: str) -> str:
raise NotImplementedError
@mcp.prompt()
def hello() -> str:
raise NotImplementedError
async with Client(mcp) as client:
assert (await client.list_tools()).ttl_ms == 1_000
assert (await client.list_resources()).ttl_ms == 2_000
assert (await client.list_resource_templates()).ttl_ms == 3_000
assert (await client.list_prompts()).ttl_ms == 4_000
assert (await client.read_resource("config://app")).ttl_ms == 5_000
assert (await client.session.discover()).ttl_ms == 6_000
+250
View File
@@ -0,0 +1,250 @@
"""Test that cancelled requests don't cause double responses."""
import anyio
import pytest
from mcp_types import (
CallToolRequest,
CallToolRequestParams,
CallToolResult,
CancelledNotification,
CancelledNotificationParams,
ClientCapabilities,
Implementation,
InitializeRequestParams,
JSONRPCNotification,
JSONRPCRequest,
ListToolsResult,
PaginatedRequestParams,
TextContent,
Tool,
)
from mcp_types.version import LATEST_HANDSHAKE_VERSION
from mcp import Client
from mcp.server import Server, ServerRequestContext
from mcp.shared.exceptions import MCPError
from mcp.shared.message import SessionMessage
@pytest.mark.anyio
async def test_server_remains_functional_after_cancel():
"""Verify server can handle new requests after a cancellation."""
# Track tool calls
call_count = 0
ev_first_call = anyio.Event()
first_request_id = None
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(
tools=[
Tool(
name="test_tool",
description="Tool for testing",
input_schema={"type": "object"},
)
]
)
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
nonlocal call_count, first_request_id
if params.name == "test_tool":
call_count += 1
if call_count == 1:
first_request_id = ctx.request_id
ev_first_call.set()
await anyio.sleep(5) # First call is slow
return CallToolResult(content=[TextContent(type="text", text=f"Call number: {call_count}")])
raise ValueError(f"Unknown tool: {params.name}") # pragma: no cover
server = Server("test-server", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool)
async with Client(server, mode="legacy") as client:
# First request (will be cancelled)
async def first_request():
try:
await client.session.send_request(
CallToolRequest(params=CallToolRequestParams(name="test_tool", arguments={})),
CallToolResult,
)
pytest.fail("First request should have been cancelled") # pragma: no cover
except MCPError:
pass # Expected
# Start first request
async with anyio.create_task_group() as tg:
tg.start_soon(first_request)
# Wait for it to start
await ev_first_call.wait()
# Cancel it
assert first_request_id is not None
await client.session.send_notification(
CancelledNotification(
params=CancelledNotificationParams(request_id=first_request_id, reason="Testing server recovery"),
)
)
# Second request (should work normally)
result = await client.call_tool("test_tool", {})
# Verify second request completed successfully
assert len(result.content) == 1
# Type narrowing for pyright
content = result.content[0]
assert content.type == "text"
assert isinstance(content, TextContent)
assert content.text == "Call number: 2"
assert call_count == 2
@pytest.mark.anyio
async def test_server_cancels_in_flight_handlers_on_transport_close():
"""When the transport closes mid-request, server.run() must cancel in-flight
handlers rather than join on them.
Without the cancel, the task group waits for the handler, which then tries
to respond through a write stream that _receive_loop already closed,
raising ClosedResourceError and crashing server.run() with exit code 1.
This drives server.run() with raw memory streams because InMemoryTransport
wraps it in its own finally-cancel (_memory.py) which masks the bug.
"""
handler_started = anyio.Event()
handler_cancelled = anyio.Event()
server_run_returned = anyio.Event()
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
handler_started.set()
try:
await anyio.sleep_forever()
finally:
handler_cancelled.set()
# unreachable: sleep_forever only exits via cancellation
raise AssertionError # pragma: no cover
server = Server("test", on_call_tool=handle_call_tool)
to_server, server_read = anyio.create_memory_object_stream[SessionMessage | Exception](10)
server_write, from_server = anyio.create_memory_object_stream[SessionMessage](10)
async def run_server():
await server.run(server_read, server_write, server.create_initialization_options())
server_run_returned.set()
init_req = JSONRPCRequest(
jsonrpc="2.0",
id=1,
method="initialize",
params=InitializeRequestParams(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ClientCapabilities(),
client_info=Implementation(name="test", version="1.0"),
).model_dump(by_alias=True, mode="json", exclude_none=True),
)
initialized = JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized")
call_req = JSONRPCRequest(
jsonrpc="2.0",
id=2,
method="tools/call",
params=CallToolRequestParams(name="slow", arguments={}).model_dump(by_alias=True, mode="json"),
)
with anyio.fail_after(5):
async with anyio.create_task_group() as tg, to_server, server_read, server_write, from_server:
tg.start_soon(run_server)
await to_server.send(SessionMessage(init_req))
await from_server.receive() # init response
await to_server.send(SessionMessage(initialized))
await to_server.send(SessionMessage(call_req))
await handler_started.wait()
# Close the server's input stream — this is what stdin EOF does.
# server.run()'s incoming_messages loop ends, finally-cancel fires,
# handler gets CancelledError, server.run() returns.
await to_server.aclose()
await server_run_returned.wait()
assert handler_cancelled.is_set()
@pytest.mark.anyio
async def test_server_handles_transport_close_with_pending_server_to_client_requests():
"""When the transport closes while handlers are blocked on server→client
requests (sampling, roots, elicitation), server.run() must still exit cleanly.
Two bugs covered:
1. _receive_loop's finally iterates _response_streams with await checkpoints
inside; the woken handler's send_request finally pops from that dict
before the next __next__() — RuntimeError: dictionary changed size.
2. The woken handler's MCPError is caught in _handle_request, which falls
through to respond() against a write stream _receive_loop already closed.
"""
handlers_started = 0
both_started = anyio.Event()
server_run_returned = anyio.Event()
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
nonlocal handlers_started
handlers_started += 1
if handlers_started == 2:
both_started.set()
# Blocks on send_request waiting for a client response that never comes.
# _receive_loop's finally will wake this with CONNECTION_CLOSED.
await ctx.session.list_roots() # pyright: ignore[reportDeprecated]
raise AssertionError # pragma: no cover
server = Server("test", on_call_tool=handle_call_tool)
to_server, server_read = anyio.create_memory_object_stream[SessionMessage | Exception](10)
server_write, from_server = anyio.create_memory_object_stream[SessionMessage](10)
async def run_server():
await server.run(server_read, server_write, server.create_initialization_options())
server_run_returned.set()
init_req = JSONRPCRequest(
jsonrpc="2.0",
id=1,
method="initialize",
params=InitializeRequestParams(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ClientCapabilities(),
client_info=Implementation(name="test", version="1.0"),
).model_dump(by_alias=True, mode="json", exclude_none=True),
)
initialized = JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized")
with anyio.fail_after(5):
async with anyio.create_task_group() as tg, to_server, server_read, server_write, from_server:
tg.start_soon(run_server)
await to_server.send(SessionMessage(init_req))
await from_server.receive() # init response
await to_server.send(SessionMessage(initialized))
# Two tool calls → two handlers → two _response_streams entries.
for rid in (2, 3):
call_req = JSONRPCRequest(
jsonrpc="2.0",
id=rid,
method="tools/call",
params=CallToolRequestParams(name="t", arguments={}).model_dump(by_alias=True, mode="json"),
)
await to_server.send(SessionMessage(call_req))
await both_started.wait()
# Drain the two roots/list requests so send_request's _write_stream.send()
# completes and both handlers are parked at response_stream_reader.receive().
await from_server.receive()
await from_server.receive()
await to_server.aclose()
# Without the fixes: RuntimeError (dict mutation) or ClosedResourceError
# (respond after write-stream close) escapes run_server and this hangs.
await server_run_returned.wait()
@@ -0,0 +1,159 @@
"""Tests for completion handler with context functionality."""
import pytest
from mcp_types import (
CompleteRequestParams,
CompleteResult,
Completion,
PromptReference,
ResourceTemplateReference,
)
from mcp import Client
from mcp.server import Server, ServerRequestContext
@pytest.mark.anyio
async def test_completion_handler_receives_context():
"""Test that the completion handler receives context correctly."""
# Track what the handler receives
received_params: CompleteRequestParams | None = None
async def handle_completion(ctx: ServerRequestContext, params: CompleteRequestParams) -> CompleteResult:
nonlocal received_params
received_params = params
return CompleteResult(completion=Completion(values=["test-completion"], total=1, has_more=False))
server = Server("test-server", on_completion=handle_completion)
async with Client(server) as client:
# Test with context
result = await client.complete(
ref=ResourceTemplateReference(type="ref/resource", uri="test://resource/{param}"),
argument={"name": "param", "value": "test"},
context_arguments={"previous": "value"},
)
# Verify handler received the context
assert received_params is not None
assert received_params.context is not None
assert received_params.context.arguments == {"previous": "value"}
assert result.completion.values == ["test-completion"]
@pytest.mark.anyio
async def test_completion_backward_compatibility():
"""Test that completion works without context (backward compatibility)."""
context_was_none = False
async def handle_completion(ctx: ServerRequestContext, params: CompleteRequestParams) -> CompleteResult:
nonlocal context_was_none
context_was_none = params.context is None
return CompleteResult(completion=Completion(values=["no-context-completion"], total=1, has_more=False))
server = Server("test-server", on_completion=handle_completion)
async with Client(server) as client:
# Test without context
result = await client.complete(
ref=PromptReference(type="ref/prompt", name="test-prompt"), argument={"name": "arg", "value": "val"}
)
# Verify context was None
assert context_was_none
assert result.completion.values == ["no-context-completion"]
@pytest.mark.anyio
async def test_dependent_completion_scenario():
"""Test a real-world scenario with dependent completions."""
async def handle_completion(ctx: ServerRequestContext, params: CompleteRequestParams) -> CompleteResult:
# Simulate database/table completion scenario
assert isinstance(params.ref, ResourceTemplateReference)
assert params.ref.uri == "db://{database}/{table}"
if params.argument.name == "database":
return CompleteResult(
completion=Completion(values=["users_db", "products_db", "analytics_db"], total=3, has_more=False)
)
assert params.argument.name == "table"
assert params.context and params.context.arguments
db = params.context.arguments.get("database")
if db == "users_db":
return CompleteResult(
completion=Completion(values=["users", "sessions", "permissions"], total=3, has_more=False)
)
else:
assert db == "products_db"
return CompleteResult(
completion=Completion(values=["products", "categories", "inventory"], total=3, has_more=False)
)
server = Server("test-server", on_completion=handle_completion)
async with Client(server) as client:
# First, complete database
db_result = await client.complete(
ref=ResourceTemplateReference(type="ref/resource", uri="db://{database}/{table}"),
argument={"name": "database", "value": ""},
)
assert "users_db" in db_result.completion.values
assert "products_db" in db_result.completion.values
# Then complete table with database context
table_result = await client.complete(
ref=ResourceTemplateReference(type="ref/resource", uri="db://{database}/{table}"),
argument={"name": "table", "value": ""},
context_arguments={"database": "users_db"},
)
assert table_result.completion.values == ["users", "sessions", "permissions"]
# Different database gives different tables
table_result2 = await client.complete(
ref=ResourceTemplateReference(type="ref/resource", uri="db://{database}/{table}"),
argument={"name": "table", "value": ""},
context_arguments={"database": "products_db"},
)
assert table_result2.completion.values == ["products", "categories", "inventory"]
@pytest.mark.anyio
async def test_completion_error_on_missing_context():
"""Test that server can raise error when required context is missing."""
async def handle_completion(ctx: ServerRequestContext, params: CompleteRequestParams) -> CompleteResult:
assert isinstance(params.ref, ResourceTemplateReference)
assert params.ref.uri == "db://{database}/{table}"
assert params.argument.name == "table"
if not params.context or not params.context.arguments or "database" not in params.context.arguments:
raise ValueError("Please select a database first to see available tables")
db = params.context.arguments.get("database")
assert db == "test_db"
return CompleteResult(completion=Completion(values=["users", "orders", "products"], total=3, has_more=False))
server = Server("test-server", on_completion=handle_completion)
async with Client(server, mode="legacy") as client:
# Try to complete table without database context - should raise error
with pytest.raises(Exception) as exc_info:
await client.complete(
ref=ResourceTemplateReference(type="ref/resource", uri="db://{database}/{table}"),
argument={"name": "table", "value": ""},
)
# Verify error message
assert "Please select a database first" in str(exc_info.value)
# Now complete with proper context - should work normally
result_with_context = await client.complete(
ref=ResourceTemplateReference(type="ref/resource", uri="db://{database}/{table}"),
argument={"name": "table", "value": ""},
context_arguments={"database": "test_db"},
)
# Should get normal completions
assert result_with_context.completion.values == ["users", "orders", "products"]
+382
View File
@@ -0,0 +1,382 @@
"""Tests for `Connection`.
`Connection` wraps an `Outbound` (the standalone stream). Construct it via the
`from_envelope` / `for_loop` factories so `protocol_version` is always
populated and `has_standalone_channel` is derived from the held outbound. Its
`notify` is best-effort (never raises); `send_raw_request` raises
`NoBackChannelError` structurally from the no-channel sentinel. Tested with a
stub `Outbound` so we can assert wire shape and inject failures.
"""
import logging
from collections.abc import Mapping
from typing import Any, Literal
import anyio
import pytest
from mcp_types import (
LATEST_PROTOCOL_VERSION,
ClientCapabilities,
CreateMessageRequest,
CreateMessageRequestParams,
ElicitationCapability,
EmptyResult,
Implementation,
ListRootsRequest,
ListRootsResult,
PingRequest,
Request,
RequestParams,
RootsCapability,
SamplingCapability,
SamplingContextCapability,
SamplingToolsCapability,
)
from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
from pydantic import BaseModel, ValidationError
from mcp.server.connection import Connection
from mcp.shared.dispatcher import CallOptions
from mcp.shared.exceptions import NoBackChannelError
_CLIENT_INFO = Implementation(name="t", version="0")
class StubOutbound:
def __init__(
self, *, result: dict[str, Any] | None = None, raise_on_send: type[BaseException] | None = None
) -> None:
self.requests: list[tuple[str, Mapping[str, Any] | None]] = []
self.notifications: list[tuple[str, Mapping[str, Any] | None]] = []
self._result = result if result is not None else {}
self._raise_on_send = raise_on_send
async def send_raw_request(
self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None
) -> dict[str, Any]:
self.requests.append((method, params))
return self._result
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
if self._raise_on_send is not None:
raise self._raise_on_send()
self.notifications.append((method, params))
# --- factories -----------------------------------------------------------------
def test_from_envelope_is_born_ready_with_no_back_channel():
"""SDK-defined: `from_envelope` populates `protocol_version`, sets `initialized`,
and holds the no-channel sentinel so `has_standalone_channel` derives False."""
conn = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
assert conn.protocol_version == LATEST_MODERN_VERSION
assert conn.initialized.is_set()
assert conn.initialize_accepted is True
assert conn.has_standalone_channel is False
assert conn.client_params is None
assert conn.session_id is None
def test_from_envelope_records_client_params_when_both_info_and_caps_supplied():
"""SDK-defined: when both client info and capabilities are supplied,
`from_envelope` synthesizes `client_params` so capability checks can run."""
caps = ClientCapabilities(sampling=SamplingCapability())
conn = Connection.from_envelope(LATEST_MODERN_VERSION, _CLIENT_INFO, caps)
assert conn.client_params is not None
assert conn.client_params.client_info.name == "t"
assert conn.client_params.capabilities.sampling is not None
assert conn.client_params.protocol_version == LATEST_MODERN_VERSION
@pytest.mark.parametrize(
("info", "caps"),
[(None, ClientCapabilities()), (_CLIENT_INFO, None)],
)
def test_from_envelope_leaves_client_params_none_when_either_is_missing(
info: Implementation | None, caps: ClientCapabilities | None
):
"""SDK-defined: `client_params` is only synthesized when both info and
caps are present; either missing leaves it `None`."""
conn = Connection.from_envelope(LATEST_MODERN_VERSION, info, caps)
assert conn.client_params is None
def test_from_envelope_with_explicit_outbound_has_standalone_channel():
"""SDK-defined: duplex modern transports pass an outbound; `has_standalone_channel`
derives True since the held outbound is not the no-channel sentinel."""
out = StubOutbound()
conn = Connection.from_envelope(LATEST_MODERN_VERSION, None, None, outbound=out)
assert conn.has_standalone_channel is True
assert conn.outbound is out
assert conn.initialized.is_set()
def test_for_loop_seeds_version_from_hint_or_latest_and_is_not_born_ready():
"""SDK-defined: `for_loop` seeds `protocol_version` from the hint when given,
else `LATEST_HANDSHAKE_VERSION`; the connection awaits the initialize handshake."""
out = StubOutbound()
conn = Connection.for_loop(out)
assert conn.protocol_version == LATEST_HANDSHAKE_VERSION
assert conn.has_standalone_channel is True
assert not conn.initialized.is_set()
assert conn.initialize_accepted is False
assert conn.client_params is None
hinted = Connection.for_loop(out, protocol_version_hint=LATEST_MODERN_VERSION)
assert hinted.protocol_version == LATEST_MODERN_VERSION
def test_for_loop_records_session_id_when_supplied():
"""SDK-defined: `for_loop` stores the `session_id` kwarg verbatim."""
conn = Connection.for_loop(StubOutbound(), session_id="sess-1")
assert conn.session_id == "sess-1"
# --- outbound channel ----------------------------------------------------------
@pytest.mark.anyio
async def test_connection_notify_forwards_to_outbound():
out = StubOutbound()
conn = Connection.for_loop(out)
await conn.notify("notifications/message", {"level": "info", "data": "hi"})
assert out.notifications == [("notifications/message", {"level": "info", "data": "hi"})]
@pytest.mark.anyio
async def test_connection_notify_swallows_broken_stream_and_debug_logs(caplog: pytest.LogCaptureFixture):
caplog.set_level(logging.DEBUG, logger="mcp.server.connection")
out = StubOutbound(raise_on_send=anyio.BrokenResourceError)
conn = Connection.for_loop(out)
await conn.notify("notifications/message", {"data": "x"}) # must not raise
assert "stream closed" in caplog.text.lower()
@pytest.mark.anyio
async def test_connection_notify_drops_when_no_standalone_channel(caplog: pytest.LogCaptureFixture):
"""SDK-defined: the no-channel sentinel debug-logs and drops; `notify` never raises."""
caplog.set_level(logging.DEBUG, logger="mcp.server.connection")
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
await conn.notify("notifications/message", {"data": "x"}) # must not raise
assert "no standalone channel" in caplog.text.lower()
@pytest.mark.anyio
async def test_connection_send_raw_request_raises_nobackchannel_when_no_standalone_channel():
"""SDK-defined: the no-channel sentinel raises structurally; `Connection` does no pre-check."""
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
with pytest.raises(NoBackChannelError):
await conn.send_raw_request("ping", None)
@pytest.mark.anyio
async def test_connection_send_raw_request_forwards_when_standalone_channel_present():
out = StubOutbound()
conn = Connection.for_loop(out)
result = await conn.send_raw_request("ping", None)
assert out.requests == [("ping", None)]
assert result == {}
@pytest.mark.anyio
async def test_connection_send_request_with_spec_type_infers_result_type():
out = StubOutbound(result={"roots": [{"uri": "file:///ws"}]})
conn = Connection.for_loop(out)
result = await conn.send_request(ListRootsRequest())
method, _ = out.requests[0]
assert method == "roots/list"
assert isinstance(result, ListRootsResult)
assert str(result.roots[0].uri) == "file:///ws"
@pytest.mark.anyio
async def test_connection_send_request_validates_result_alias_only():
"""Peer results validate alias-only; a snake_case key from the wire is
ignored as extra, not populated by Python field name."""
snake = {"role": "assistant", "content": {"type": "text", "text": "x"}, "model": "m", "stop_reason": "endTurn"}
conn = Connection.for_loop(StubOutbound(result=snake))
result = await conn.send_request(CreateMessageRequest(params=CreateMessageRequestParams(messages=[], max_tokens=1)))
assert result.stop_reason is None
@pytest.mark.anyio
async def test_connection_send_request_with_result_type_kwarg_validates_custom_type():
out = StubOutbound(result={})
conn = Connection.for_loop(out)
result = await conn.send_request(PingRequest(), result_type=EmptyResult)
assert isinstance(result, EmptyResult)
@pytest.mark.anyio
async def test_connection_send_request_nonconforming_result_raises_validation_error():
conn = Connection.for_loop(StubOutbound(result={"bogus": 1}))
with pytest.raises(ValidationError):
await conn.send_request(ListRootsRequest())
@pytest.mark.anyio
async def test_send_request_validates_the_client_result_against_the_surface_schema():
"""A spec-method result that fails the per-version surface schema raises
`ValidationError` even when the caller's `result_type` would accept it."""
conn = Connection.for_loop(StubOutbound(result={"roots": "nope"}))
with pytest.raises(ValidationError):
await conn.send_request(ListRootsRequest(), result_type=EmptyResult)
@pytest.mark.anyio
async def test_send_request_passes_a_spec_valid_client_result():
"""A spec-valid client result passes the surface gate and parses to the typed model."""
conn = Connection.for_loop(StubOutbound(result={"roots": [{"uri": "file:///ws"}]}))
assert conn.protocol_version == LATEST_HANDSHAKE_VERSION
result = await conn.send_request(ListRootsRequest())
assert isinstance(result, ListRootsResult)
assert str(result.roots[0].uri) == "file:///ws"
class _CustomRequest(Request[RequestParams | None, Literal["custom/echo"]]):
method: Literal["custom/echo"] = "custom/echo"
params: RequestParams | None = None
class _CustomResult(BaseModel):
value: int
@pytest.mark.anyio
async def test_send_request_skips_the_surface_gate_when_method_absent_at_version():
"""Surface row absent for the negotiated version: gate is bypassed and only
the inferred result type validates."""
conn = Connection.for_loop(StubOutbound(result={}), protocol_version_hint=LATEST_MODERN_VERSION)
result = await conn.send_request(PingRequest())
assert isinstance(result, EmptyResult)
@pytest.mark.anyio
async def test_send_request_with_a_custom_method_skips_the_surface_gate():
"""Non-spec methods are not blocked by the surface gate; `result_type` validates."""
conn = Connection.for_loop(StubOutbound(result={"value": 7}))
result = await conn.send_request(_CustomRequest(), result_type=_CustomResult)
assert isinstance(result, _CustomResult)
assert result.value == 7
@pytest.mark.anyio
async def test_connection_ping_sends_ping_on_standalone():
out = StubOutbound()
conn = Connection.for_loop(out)
await conn.ping()
assert out.requests == [("ping", None)]
@pytest.mark.anyio
async def test_connection_log_sends_logging_message_notification():
out = StubOutbound()
conn = Connection.for_loop(out)
await conn.log("info", {"k": "v"}, logger="my.logger") # pyright: ignore[reportDeprecated]
method, params = out.notifications[0]
assert method == "notifications/message"
assert params is not None
assert params["level"] == "info"
assert params["data"] == {"k": "v"}
assert params["logger"] == "my.logger"
@pytest.mark.anyio
async def test_connection_log_with_meta_includes_meta_in_params():
out = StubOutbound()
conn = Connection.for_loop(out)
await conn.log("info", "x", meta={"traceId": "abc"}) # pyright: ignore[reportDeprecated]
_, params = out.notifications[0]
assert params is not None
assert params["_meta"] == {"traceId": "abc"}
@pytest.mark.anyio
async def test_connection_list_changed_notifications_send_correct_methods():
out = StubOutbound()
conn = Connection.for_loop(out)
await conn.send_tool_list_changed()
await conn.send_prompt_list_changed()
await conn.send_resource_list_changed()
await conn.send_resource_updated("file:///workspace/a.txt")
methods = [m for m, _ in out.notifications]
assert methods == [
"notifications/tools/list_changed",
"notifications/prompts/list_changed",
"notifications/resources/list_changed",
"notifications/resources/updated",
]
assert out.notifications[-1][1] == {"uri": "file:///workspace/a.txt"}
@pytest.mark.anyio
async def test_connection_send_tool_list_changed_with_meta_includes_meta_only_params():
out = StubOutbound()
conn = Connection.for_loop(out)
await conn.send_tool_list_changed(meta={"k": 1})
assert out.notifications == [("notifications/tools/list_changed", {"_meta": {"k": 1}})]
# --- check_capability ----------------------------------------------------------
def test_connection_check_capability_false_when_no_client_params_recorded():
"""SDK-defined: `check_capability` returns False when no `client_params`
were recorded, regardless of which factory built the connection."""
conn = Connection.for_loop(StubOutbound())
assert conn.check_capability(ClientCapabilities(sampling=SamplingCapability())) is False
# Same for a born-ready connection that supplied neither info nor caps.
assert Connection.from_envelope(LATEST_MODERN_VERSION, None, None).check_capability(ClientCapabilities()) is False
@pytest.mark.parametrize(
("have", "want", "expected"),
[
(ClientCapabilities(roots=None), ClientCapabilities(roots=RootsCapability()), False),
(
ClientCapabilities(roots=RootsCapability(list_changed=False)),
ClientCapabilities(roots=RootsCapability(list_changed=True)),
False,
),
(ClientCapabilities(sampling=None), ClientCapabilities(sampling=SamplingCapability()), False),
(
ClientCapabilities(sampling=SamplingCapability()),
ClientCapabilities(sampling=SamplingCapability(context=SamplingContextCapability())),
False,
),
(
ClientCapabilities(sampling=SamplingCapability()),
ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability())),
False,
),
(
ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability())),
ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability())),
True,
),
(
ClientCapabilities(sampling=SamplingCapability(context=SamplingContextCapability())),
ClientCapabilities(sampling=SamplingCapability(context=SamplingContextCapability())),
True,
),
(ClientCapabilities(experimental=None), ClientCapabilities(experimental={"a": {}}), False),
(ClientCapabilities(experimental={"a": {}}), ClientCapabilities(experimental={"b": {}}), False),
(ClientCapabilities(experimental={"a": {"x": 1}}), ClientCapabilities(experimental={"a": {"x": 2}}), False),
(ClientCapabilities(experimental={"a": {}}), ClientCapabilities(experimental={"a": {}}), True),
],
)
def test_check_capability_per_field_branches(have: ClientCapabilities, want: ClientCapabilities, expected: bool):
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, _CLIENT_INFO, have)
assert conn.check_capability(want) is expected
def test_connection_check_capability_true_when_client_declares_it():
conn = Connection.from_envelope(
LATEST_PROTOCOL_VERSION,
_CLIENT_INFO,
ClientCapabilities(sampling=SamplingCapability(), roots=RootsCapability(list_changed=True)),
)
assert conn.check_capability(ClientCapabilities(sampling=SamplingCapability())) is True
assert conn.check_capability(ClientCapabilities(roots=RootsCapability(list_changed=True))) is True
assert conn.check_capability(ClientCapabilities(elicitation=ElicitationCapability())) is False
+135
View File
@@ -0,0 +1,135 @@
"""Tests for the SEP-2133 extensions capability negotiation plumbing.
The extension-map negotiation is independent of any concrete extension (Apps,
Tasks): the lowlevel `Server` advertises `self.extensions` under
`ServerCapabilities.extensions`, a client mirrors its own support under
`ClientCapabilities.extensions`, and `Connection.check_capability` resolves the
server-side query. These tests pin that plumbing end-to-end and at the unit
level. Per-extension contribution wiring lives in `test_extension.py`; this file
covers only the capability advertisement and negotiation.
"""
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp.client import advertise
from mcp.client.client import Client
from mcp.server import Server, ServerRequestContext
from mcp.server.extension import Extension
from mcp.server.mcpserver import MCPServer
pytestmark = pytest.mark.anyio
_EXTENSION_ID = "com.example/x"
_OTHER_EXTENSION_ID = "com.example/other"
class _Extension(Extension):
identifier = _EXTENSION_ID
def settings(self) -> dict[str, object]:
return {"k": 1}
def test_get_capabilities_omits_extensions_when_none_registered() -> None:
"""SDK-defined: a lowlevel `Server` with an empty `extensions` map advertises
`ServerCapabilities.extensions` as `None`, not an empty map."""
server = Server("bare")
assert server.get_capabilities().extensions is None
def test_get_capabilities_advertises_populated_self_extensions() -> None:
"""SDK-defined: `get_capabilities` reads `self.extensions` (the map higher
layers populate) and advertises it under `ServerCapabilities.extensions`."""
server = Server("with-ext")
settings = {"k": 1}
server.extensions = {_EXTENSION_ID: settings}
assert server.get_capabilities().extensions == {_EXTENSION_ID: settings}
async def test_modern_connection_carries_the_advertised_extensions_map() -> None:
"""SDK-defined: over a modern (`server/discover`) connection the client reads
the server's advertised extension map from `server_capabilities`."""
server = MCPServer("host", extensions=[_Extension()])
async with Client(server, mode="auto") as client:
assert client.server_capabilities.extensions == snapshot({"com.example/x": {"k": 1}})
async def test_legacy_handshake_drops_the_extensions_map() -> None:
"""Pinned gap: the handshake-era `initialize` result is serialized against the
2025 wire schema, which has no `extensions` field, so a legacy handshake cannot
carry it; the client sees `None` even though the server advertised one."""
server = MCPServer("host", extensions=[_Extension()])
async with Client(server, mode="legacy") as client:
assert client.server_capabilities.extensions is None
async def test_server_accepts_capability_for_client_advertised_extension() -> None:
"""SDK-defined: a client advertising `extensions={id: ...}` makes the
server-side `check_client_capability` return True when queried for that id.
Observed inside a tool handler."""
queried = types.ClientCapabilities(extensions={_EXTENSION_ID: {}})
supported: list[bool] = []
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
assert params.name == "probe"
supported.append(ctx.session.check_client_capability(queried))
return types.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("checker", on_call_tool=call_tool, on_list_tools=list_tools)
async with Client(server, extensions=[advertise(_EXTENSION_ID, {"mimeTypes": ["text/html"]})]) as client:
await client.call_tool("probe", {})
assert supported == [True]
async def test_server_rejects_capability_for_undeclared_extension() -> None:
"""SDK-defined: when the client advertises one extension, a server query for a
*different* identifier returns False - presence, not value, is the check."""
queried = types.ClientCapabilities(extensions={_OTHER_EXTENSION_ID: {}})
supported: list[bool] = []
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
assert params.name == "probe"
supported.append(ctx.session.check_client_capability(queried))
return types.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("checker", on_call_tool=call_tool, on_list_tools=list_tools)
async with Client(server, extensions=[advertise(_EXTENSION_ID, {"mimeTypes": ["text/html"]})]) as client:
await client.call_tool("probe", {})
assert supported == [False]
async def test_server_rejects_capability_when_client_advertises_no_extensions() -> None:
"""SDK-defined: a client that declares no extensions makes any server
`check_client_capability` query for an extension return False."""
queried = types.ClientCapabilities(extensions={_EXTENSION_ID: {}})
supported: list[bool] = []
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
assert params.name == "probe"
supported.append(ctx.session.check_client_capability(queried))
return types.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("checker", on_call_tool=call_tool, on_list_tools=list_tools)
async with Client(server) as client:
await client.call_tool("probe", {})
assert supported == [False]
+207
View File
@@ -0,0 +1,207 @@
"""Tests for lifespan functionality in both low-level and MCPServer servers."""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import anyio
import pytest
from mcp_types import (
CallToolRequestParams,
CallToolResult,
ClientCapabilities,
Implementation,
InitializeRequestParams,
JSONRPCMessage,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
TextContent,
)
from pydantic import TypeAdapter
from mcp.server import ServerRequestContext
from mcp.server.lowlevel.server import NotificationOptions, Server
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.models import InitializationOptions
from mcp.shared.message import SessionMessage
@pytest.mark.anyio
async def test_lowlevel_server_lifespan():
"""Test that lifespan works in low-level server."""
@asynccontextmanager
async def test_lifespan(server: Server) -> AsyncIterator[dict[str, bool]]:
"""Test lifespan context that tracks startup/shutdown."""
context = {"started": False, "shutdown": False}
try:
context["started"] = True
yield context
finally:
context["shutdown"] = True
# Create a tool that accesses lifespan context
async def check_lifespan(
ctx: ServerRequestContext[dict[str, bool]], params: CallToolRequestParams
) -> CallToolResult:
assert isinstance(ctx.lifespan_context, dict)
assert ctx.lifespan_context["started"]
assert not ctx.lifespan_context["shutdown"]
return CallToolResult(content=[TextContent(type="text", text="true")])
server = Server[dict[str, bool]]("test", lifespan=test_lifespan, on_call_tool=check_lifespan)
# Create memory streams for testing
send_stream1, receive_stream1 = anyio.create_memory_object_stream[SessionMessage](100)
send_stream2, receive_stream2 = anyio.create_memory_object_stream[SessionMessage](100)
# Run server in background task
async with anyio.create_task_group() as tg, send_stream1, receive_stream1, send_stream2, receive_stream2:
async def run_server():
await server.run(
receive_stream1,
send_stream2,
InitializationOptions(
server_name="test",
server_version="0.1.0",
capabilities=server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
),
raise_exceptions=True,
)
tg.start_soon(run_server)
# Initialize the server
params = InitializeRequestParams(
protocol_version="2024-11-05",
capabilities=ClientCapabilities(),
client_info=Implementation(name="test-client", version="0.1.0"),
)
await send_stream1.send(
SessionMessage(
JSONRPCRequest(
jsonrpc="2.0",
id=1,
method="initialize",
params=TypeAdapter(InitializeRequestParams).dump_python(params),
)
)
)
response = await receive_stream2.receive()
response = response.message
# Send initialized notification
await send_stream1.send(SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized")))
# Call the tool to verify lifespan context
await send_stream1.send(
SessionMessage(
JSONRPCRequest(
jsonrpc="2.0",
id=2,
method="tools/call",
params={"name": "check_lifespan", "arguments": {}},
)
)
)
# Get response and verify
response = await receive_stream2.receive()
response = response.message
assert isinstance(response, JSONRPCMessage)
assert isinstance(response, JSONRPCResponse)
assert response.result["content"][0]["text"] == "true"
# Cancel server task
tg.cancel_scope.cancel()
@pytest.mark.anyio
async def test_mcpserver_server_lifespan():
"""Test that lifespan works in MCPServer server."""
@asynccontextmanager
async def test_lifespan(server: MCPServer) -> AsyncIterator[dict[str, bool]]:
"""Test lifespan context that tracks startup/shutdown."""
context = {"started": False, "shutdown": False}
try:
context["started"] = True
yield context
finally:
context["shutdown"] = True
server = MCPServer("test", lifespan=test_lifespan)
# Create memory streams for testing
send_stream1, receive_stream1 = anyio.create_memory_object_stream[SessionMessage](100)
send_stream2, receive_stream2 = anyio.create_memory_object_stream[SessionMessage](100)
# Add a tool that checks lifespan context
@server.tool()
def check_lifespan(ctx: Context) -> bool:
"""Tool that checks lifespan context."""
assert isinstance(ctx.request_context.lifespan_context, dict)
assert ctx.request_context.lifespan_context["started"]
assert not ctx.request_context.lifespan_context["shutdown"]
return True
# Run server in background task
async with anyio.create_task_group() as tg, send_stream1, receive_stream1, send_stream2, receive_stream2:
async def run_server():
await server._lowlevel_server.run(
receive_stream1,
send_stream2,
server._lowlevel_server.create_initialization_options(),
raise_exceptions=True,
)
tg.start_soon(run_server)
# Initialize the server
params = InitializeRequestParams(
protocol_version="2024-11-05",
capabilities=ClientCapabilities(),
client_info=Implementation(name="test-client", version="0.1.0"),
)
await send_stream1.send(
SessionMessage(
JSONRPCRequest(
jsonrpc="2.0",
id=1,
method="initialize",
params=TypeAdapter(InitializeRequestParams).dump_python(params),
)
)
)
response = await receive_stream2.receive()
response = response.message
# Send initialized notification
await send_stream1.send(SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized")))
# Call the tool to verify lifespan context
await send_stream1.send(
SessionMessage(
JSONRPCRequest(
jsonrpc="2.0",
id=2,
method="tools/call",
params={"name": "check_lifespan", "arguments": {}},
)
)
)
# Get response and verify
response = await receive_stream2.receive()
response = response.message
assert isinstance(response, JSONRPCMessage)
assert isinstance(response, JSONRPCResponse)
assert response.result["content"][0]["text"] == "true"
# Cancel server task
tg.cancel_scope.cancel()
@@ -0,0 +1,48 @@
import anyio
import pytest
from mcp.server.lowlevel.server import Server
from mcp.shared.message import SessionMessage
@pytest.mark.anyio
async def test_server_run_exits_cleanly_when_transport_yields_exception_then_closes():
"""Regression test for #1967 / #2064.
Exercises the real Server.run() path with real memory streams, reproducing
what happens in stateless streamable HTTP when a POST handler throws:
1. Transport yields an Exception into the read stream
(streamable_http.py does this in its broad POST-handler except).
2. Transport closes the read stream (terminate() in stateless mode).
3. The read loop exits and closes the write stream.
Before the fix, the message handler tried to send_log_message through the
closed write stream, raising ClosedResourceError and crashing server.run().
After the fix (and now in the dispatcher), the exception is only logged
locally.
"""
server = Server("test-server")
read_send, read_recv = anyio.create_memory_object_stream[SessionMessage | Exception](1)
# Zero-buffer on the write stream forces send() to block until received.
# With no receiver, a send() sits blocked until the read loop exits its
# `async with read_stream, write_stream:` block and closes the stream, at
# which point the blocked send raises ClosedResourceError. This
# deterministically reproduces the race without sleeps.
write_send, write_recv = anyio.create_memory_object_stream[SessionMessage](0)
# What the streamable HTTP transport does: push the exception, then close.
read_send.send_nowait(RuntimeError("simulated transport error"))
read_send.close()
with anyio.fail_after(5):
# stateless=True so server.run doesn't wait for initialize handshake.
# Before the fix, this raised ExceptionGroup(ClosedResourceError).
await server.run(read_recv, write_send, server.create_initialization_options())
# write_send was closed inside run's `async with`; receive_nowait raises
# EndOfStream iff the buffer is empty (i.e., server wrote nothing).
with pytest.raises(anyio.EndOfStream):
write_recv.receive_nowait()
write_recv.close()
@@ -0,0 +1,44 @@
"""Tests for tool annotations in low-level server."""
import pytest
from mcp_types import ListToolsResult, PaginatedRequestParams, Tool, ToolAnnotations
from mcp import Client
from mcp.server import Server, ServerRequestContext
@pytest.mark.anyio
async def test_lowlevel_server_tool_annotations():
"""Test that tool annotations work in low-level server."""
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(
tools=[
Tool(
name="echo",
description="Echo a message back",
input_schema={
"type": "object",
"properties": {
"message": {"type": "string"},
},
"required": ["message"],
},
annotations=ToolAnnotations(
title="Echo Tool",
read_only_hint=True,
),
)
]
)
server = Server("test", on_list_tools=handle_list_tools)
async with Client(server) as client:
tools_result = await client.list_tools()
assert len(tools_result.tools) == 1
assert tools_result.tools[0].name == "echo"
assert tools_result.tools[0].annotations is not None
assert tools_result.tools[0].annotations.title == "Echo Tool"
assert tools_result.tools[0].annotations.read_only_hint is True
+334
View File
@@ -0,0 +1,334 @@
"""Tests for `OpenTelemetryMiddleware` (the context-tier OTel span middleware).
Every `Server` ships `OpenTelemetryMiddleware` at the head of `Server.middleware`,
so these tests assert against the default-configured server rather than appending
the middleware by hand.
"""
from collections.abc import Callable
from dataclasses import replace
from typing import Any
import anyio
import pytest
from mcp_types import (
INTERNAL_ERROR,
INVALID_PARAMS,
CallToolRequestParams,
CallToolResult,
GetPromptRequestParams,
GetPromptResult,
ListToolsResult,
NotificationParams,
PaginatedRequestParams,
RequestParamsMeta,
Tool,
)
from opentelemetry.trace import SpanKind, StatusCode
from mcp.server._otel import OpenTelemetryMiddleware
from mcp.server.context import CallNext
from mcp.server.lowlevel.server import Server
from mcp.shared._otel import inject_trace_context, otel_span
from mcp.shared.exceptions import MCPError
from .conftest import SpanCapture
from .test_runner import Ctx, SrvT, connected_runner
@pytest.fixture
def server() -> SrvT:
async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})])
return Server(name="test-server", version="0.0.1", on_list_tools=list_tools)
async def _ok_tool(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
return {"content": [], "isError": False}
def test_server_ships_opentelemetry_middleware_by_default() -> None:
server = Server(name="test-server", version="0.0.1")
assert any(isinstance(m, OpenTelemetryMiddleware) for m in server.middleware)
@pytest.mark.anyio
async def test_emits_server_span_with_method_and_target(server: SrvT, spans: SpanCapture):
server.add_request_handler("tools/call", CallToolRequestParams, _ok_tool)
async with connected_runner(server) as (client, _):
spans.clear()
result = await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
assert result == {"content": [], "isError": False}
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.name == "tools/call mytool"
assert span.attributes is not None
assert span.attributes["mcp.method.name"] == "tools/call"
assert span.attributes["gen_ai.operation.name"] == "execute_tool"
assert span.attributes["gen_ai.tool.name"] == "mytool"
assert isinstance(span.attributes["jsonrpc.request.id"], str)
assert span.status.status_code == StatusCode.UNSET
@pytest.mark.anyio
async def test_tool_error_dict_result_sets_error_type(server: SrvT, spans: SpanCapture):
async def err_tool(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
return {"content": [], "isError": True}
server.add_request_handler("tools/call", CallToolRequestParams, err_tool)
async with connected_runner(server) as (client, _):
spans.clear()
await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.attributes is not None
assert span.attributes["error.type"] == "tool_error"
assert span.status.status_code == StatusCode.ERROR
@pytest.mark.anyio
async def test_tool_error_model_result_sets_error_type(server: SrvT, spans: SpanCapture):
async def err_tool(ctx: Ctx, params: CallToolRequestParams) -> CallToolResult:
return CallToolResult(content=[], is_error=True)
server.add_request_handler("tools/call", CallToolRequestParams, err_tool)
async with connected_runner(server) as (client, _):
spans.clear()
await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.attributes is not None
assert span.attributes["error.type"] == "tool_error"
assert span.status.status_code == StatusCode.ERROR
@pytest.mark.anyio
async def test_snake_case_dict_result_is_not_a_tool_error(server: SrvT, spans: SpanCapture):
# `is_error` is alias-only on the wire, so serialization drops it; the result reaches the
# client as a success and the span must not contradict that.
async def err_tool(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
return {"content": [], "is_error": True}
server.add_request_handler("tools/call", CallToolRequestParams, err_tool)
async with connected_runner(server) as (client, _):
spans.clear()
result = await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
assert result == {"content": []}
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.attributes is not None
assert "error.type" not in span.attributes
assert span.status.status_code == StatusCode.UNSET
@pytest.mark.anyio
async def test_named_non_tool_prompt_method_omits_gen_ai_attrs(server: SrvT, spans: SpanCapture):
async def custom(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
return {"content": [], "isError": False}
server.add_request_handler("custom/op", CallToolRequestParams, custom)
async with connected_runner(server) as (client, _):
spans.clear()
await client.send_raw_request("custom/op", {"name": "thing", "arguments": {}})
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.name == "custom/op thing"
assert span.attributes is not None
assert "gen_ai.operation.name" not in span.attributes
assert "gen_ai.tool.name" not in span.attributes
assert "gen_ai.prompt.name" not in span.attributes
@pytest.mark.anyio
async def test_prompt_get_sets_prompt_name(server: SrvT, spans: SpanCapture):
async def get_prompt(ctx: Ctx, params: GetPromptRequestParams) -> GetPromptResult:
return GetPromptResult(messages=[])
server.add_request_handler("prompts/get", GetPromptRequestParams, get_prompt)
async with connected_runner(server) as (client, _):
spans.clear()
await client.send_raw_request("prompts/get", {"name": "myprompt"})
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.name == "prompts/get myprompt"
assert span.attributes is not None
assert span.attributes["gen_ai.prompt.name"] == "myprompt"
assert "gen_ai.operation.name" not in span.attributes
@pytest.mark.anyio
async def test_notification_span_omits_request_id(server: SrvT, spans: SpanCapture):
async def on_roots(ctx: Ctx, params: NotificationParams | None) -> None:
return None
server.add_notification_handler("notifications/roots/list_changed", NotificationParams, on_roots)
async with connected_runner(server) as (client, _):
spans.clear()
await client.notify("notifications/roots/list_changed", None)
await anyio.wait_all_tasks_blocked()
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.name == "notifications/roots/list_changed"
assert span.attributes is not None
assert span.attributes["mcp.method.name"] == "notifications/roots/list_changed"
assert "jsonrpc.request.id" not in span.attributes
def _ambient(rewrite_meta: Callable[[RequestParamsMeta | None], RequestParamsMeta | None]) -> Any:
"""A middleware placed outside `OpenTelemetryMiddleware` (head of
`Server.middleware`) that opens an ambient SERVER span around the request
and rewrites `ctx.meta` via `rewrite_meta`, so the context-tier span sees
the no-traceparent path yet has a current span to nest under."""
async def middleware(ctx: Ctx, call_next: CallNext) -> Any:
with otel_span("ambient", kind=SpanKind.SERVER):
return await call_next(replace(ctx, meta=rewrite_meta(ctx.meta)))
return middleware
@pytest.mark.anyio
async def test_nests_under_ambient_span_when_no_traceparent(server: SrvT, spans: SpanCapture):
"""With no `_meta` on the inbound message (a non-SDK client), the span must
parent to the ambient current span rather than become an orphan root.
SDK-defined: SEP-414 only covers the traceparent-present case."""
# The in-process client always injects `_meta.traceparent`; drop it so the
# span sees the no-carrier path.
server.middleware.insert(0, _ambient(lambda _meta: None))
async with connected_runner(server) as (client, _):
spans.clear()
await client.send_raw_request("tools/list", None)
server_spans = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert len(server_spans) == 2
[outer] = [s for s in server_spans if s.parent is None]
[inner] = [s for s in server_spans if s.parent is not None]
assert inner.context is not None and outer.context is not None
assert inner.parent is not None
assert inner.context.trace_id == outer.context.trace_id
assert inner.parent.span_id == outer.context.span_id
@pytest.mark.anyio
async def test_nests_under_ambient_span_when_meta_lacks_traceparent(server: SrvT, spans: SpanCapture):
"""`_meta` is present but carries no `traceparent` (e.g. only a
`progressToken`). `extract()` would yield an empty Context here, which
would orphan the span; the middleware must fall through to ambient
parenting just as if `_meta` were absent."""
server.middleware.insert(0, _ambient(lambda _meta: {"progressToken": "tok"}))
async with connected_runner(server) as (client, _):
spans.clear()
await client.send_raw_request("tools/list", None)
server_spans = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert len(server_spans) == 2
[outer] = [s for s in server_spans if s.parent is None]
[inner] = [s for s in server_spans if s.parent is not None]
assert inner.context is not None and outer.context is not None
assert inner.parent is not None
assert inner.context.trace_id == outer.context.trace_id
assert inner.parent.span_id == outer.context.span_id
@pytest.mark.anyio
async def test_extracts_trace_context_from_meta(server: SrvT, spans: SpanCapture):
meta: dict[str, Any] = {}
inject_trace_context(meta)
async with connected_runner(server) as (client, _):
spans.clear()
await client.send_raw_request("tools/list", {"_meta": meta})
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.parent is not None
@pytest.mark.anyio
async def test_records_error_status_on_mcp_error(server: SrvT, spans: SpanCapture):
async with connected_runner(server) as (client, _):
spans.clear()
with pytest.raises(MCPError) as exc:
await client.send_raw_request("resources/list", None)
assert exc.value.error.code != 0
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.status.status_code == StatusCode.ERROR
assert span.status.description == "Method not found"
assert span.attributes is not None
assert span.attributes["error.type"] == str(exc.value.error.code)
assert span.attributes["rpc.response.status_code"] == str(exc.value.error.code)
assert not [e for e in span.events if e.name == "exception"]
@pytest.mark.anyio
async def test_validation_failure_sets_sanitized_status(server: SrvT, spans: SpanCapture):
server.add_request_handler("tools/call", CallToolRequestParams, _ok_tool)
async with connected_runner(server) as (client, _):
spans.clear()
with pytest.raises(MCPError):
await client.send_raw_request("tools/call", {"name": 123})
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.status.status_code == StatusCode.ERROR
assert span.status.description == "Invalid request parameters"
assert span.attributes is not None
assert span.attributes["error.type"] == str(INVALID_PARAMS)
assert span.attributes["rpc.response.status_code"] == str(INVALID_PARAMS)
assert span.attributes["gen_ai.operation.name"] == "execute_tool"
assert "gen_ai.tool.name" not in span.attributes
assert not span.events
@pytest.mark.anyio
async def test_records_error_status_on_handler_exception(server: SrvT, spans: SpanCapture):
async def failing(ctx: Ctx, params: PaginatedRequestParams | None) -> Any:
raise ValueError("handler blew up")
server.add_request_handler("tools/list", PaginatedRequestParams, failing)
async with connected_runner(server) as (client, _):
spans.clear()
with pytest.raises(MCPError):
await client.send_raw_request("tools/list", None)
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.status.status_code == StatusCode.ERROR
assert span.status.description == "handler blew up"
assert span.attributes is not None
assert span.attributes["error.type"] == "ValueError"
[event] = [e for e in span.events if e.name == "exception"]
assert event.attributes is not None
assert event.attributes["exception.type"] == "ValueError"
@pytest.mark.anyio
async def test_records_error_status_on_malformed_spec_result(server: SrvT, spans: SpanCapture):
"""Result serialization runs inside the span, so a handler returning a
malformed dict for a spec method (INTERNAL_ERROR on the wire) is recorded
on the span rather than closing it as a success."""
async def bad_result(ctx: Ctx, params: PaginatedRequestParams | None) -> dict[str, Any]:
return {"tools": 42}
server.add_request_handler("tools/list", PaginatedRequestParams, bad_result)
async with connected_runner(server) as (client, _):
spans.clear()
with pytest.raises(MCPError) as exc:
await client.send_raw_request("tools/list", None)
assert exc.value.error.code == INTERNAL_ERROR
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.status.status_code == StatusCode.ERROR
assert span.attributes is not None
assert span.attributes["error.type"] == str(INTERNAL_ERROR)
assert span.attributes["rpc.response.status_code"] == str(INTERNAL_ERROR)
@pytest.mark.anyio
async def test_passes_rewritten_context_through(server: SrvT, spans: SpanCapture):
seen_arguments: dict[str, Any] = {}
async def call_tool(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
seen_arguments.update(params.arguments or {})
return {"content": [], "isError": False}
async def inject_arg(ctx: Ctx, call_next: CallNext) -> Any:
assert ctx.params is not None
arguments = {**ctx.params.get("arguments", {}), "injected": True}
return await call_next(replace(ctx, params={**ctx.params, "arguments": arguments}))
server.add_request_handler("tools/call", CallToolRequestParams, call_tool)
server.middleware.append(inject_arg)
async with connected_runner(server) as (client, _):
spans.clear()
await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {"x": 1}})
assert seen_arguments == {"x": 1, "injected": True}
[span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER]
assert span.name == "tools/call mytool"
+58
View File
@@ -0,0 +1,58 @@
import base64
import pytest
from mcp_types import (
BlobResourceContents,
ReadResourceRequestParams,
ReadResourceResult,
TextResourceContents,
)
from mcp import Client
from mcp.server import Server, ServerRequestContext
pytestmark = pytest.mark.anyio
async def test_read_resource_text():
async def handle_read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
return ReadResourceResult(
contents=[TextResourceContents(uri=str(params.uri), text="Hello World", mime_type="text/plain")]
)
server = Server("test", on_read_resource=handle_read_resource)
async with Client(server) as client:
result = await client.read_resource("test://resource")
assert len(result.contents) == 1
content = result.contents[0]
assert isinstance(content, TextResourceContents)
assert content.text == "Hello World"
assert content.mime_type == "text/plain"
async def test_read_resource_binary():
binary_data = b"Hello World"
async def handle_read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
return ReadResourceResult(
contents=[
BlobResourceContents(
uri=str(params.uri),
blob=base64.b64encode(binary_data).decode("utf-8"),
mime_type="application/octet-stream",
)
]
)
server = Server("test", on_read_resource=handle_read_resource)
async with Client(server) as client:
result = await client.read_resource("test://resource")
assert len(result.contents) == 1
content = result.contents[0]
assert isinstance(content, BlobResourceContents)
assert content.mime_type == "application/octet-stream"
assert base64.b64decode(content.blob) == binary_data
+479
View File
@@ -0,0 +1,479 @@
"""Unit tests for `mcp.server.request_state`: codec, security policy, and default principal binding."""
import base64
import string
from collections.abc import Callable
from typing import Any, cast
import pytest
from inline_snapshot import snapshot
from mcp.server.auth.middleware.auth_context import auth_context_var
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, authorization_context
from mcp.server.auth.provider import AccessToken, principal_components
from mcp.server.context import ServerRequestContext
from mcp.server.request_state import (
AESGCMRequestStateCodec,
InvalidRequestState,
RequestStateSecurity,
authenticated_principal,
)
_TOKEN_PREFIX = "v1."
_KID_LEN = 4
_NONCE_LEN = 12
_GCM_TAG_LEN = 16
_BODY_FLOOR = _KID_LEN + _NONCE_LEN + _GCM_TAG_LEN
_B64URL_ALPHABET = set(string.ascii_letters + string.digits + "-_")
_KEY_A = b"a" * 32
_KEY_B = b"b" * 32
_KEY_OLD = b"o" * 32
_KEY_NEW = b"n" * 32
# Distinctive plaintext: opacity and log-secrecy assertions search for it.
_PAYLOAD = b"sentinel-plaintext-3f9c"
# `InvalidRequestState` messages are short log-only reason codes, never payload.
_REASON_CODE_MAX_LEN = 40
def _b64u_nopad(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode().rstrip("=")
def _decode_body(token: str) -> bytes:
body = token.removeprefix(_TOKEN_PREFIX)
return base64.urlsafe_b64decode(body + "=" * (-len(body) % 4))
def _flip_body_byte(token: str, index: int) -> str:
raw = bytearray(_decode_body(token))
raw[index] ^= 0xFF
return _TOKEN_PREFIX + _b64u_nopad(bytes(raw))
def _flip_prefix_char(token: str) -> str:
return "x" + token[1:]
def _flip_kid_byte(token: str) -> str:
return _flip_body_byte(token, 0)
def _flip_nonce_byte(token: str) -> str:
return _flip_body_byte(token, _KID_LEN)
def _flip_ciphertext_byte(token: str) -> str:
return _flip_body_byte(token, _KID_LEN + _NONCE_LEN)
def _flip_tag_byte(token: str) -> str:
return _flip_body_byte(token, -1)
def _inject_junk_chars(body: str) -> str:
return body[:10] + "!@\n*" + body[10:]
def _append_newline(body: str) -> str:
return body + "\n"
def _append_padding(body: str) -> str:
return body + "=" * (-len(body) % 4 or 4)
def _bare_context() -> ServerRequestContext[Any, Any]:
return ServerRequestContext(
session=cast("Any", None),
lifespan_context={},
protocol_version="2026-07-28",
method="tools/call",
)
class _StaticCodec:
"""Minimal `RequestStateCodec` stand-in for policy tests; no real crypto."""
def seal(self, payload: bytes) -> str:
return payload.hex()
def unseal(self, token: str) -> bytes:
return bytes.fromhex(token)
# -- AESGCMRequestStateCodec --------------------------------------------------
@pytest.mark.parametrize(
"payload",
[
pytest.param(b"", id="empty"),
pytest.param(b"plain ascii state", id="ascii"),
pytest.param("ünïcødé 状態".encode(), id="multi-byte-utf8"),
pytest.param(bytes(range(256)), id="raw-binary"),
pytest.param(bytes(range(256)) * 256, id="64KiB"),
],
)
def test_seal_unseal_round_trips_any_payload(payload: bytes) -> None:
"""SDK-defined: the codec is byte-transparent, so any payload survives seal/unseal unchanged."""
codec = AESGCMRequestStateCodec([_KEY_A])
assert codec.unseal(codec.seal(payload)) == payload
def test_a_sealed_token_is_v1_plus_unpadded_b64url_over_kid_nonce_and_ciphertext() -> None:
"""SDK-defined token format: "v1." plus unpadded base64url over kid(4) || nonce(12) || ciphertext+tag."""
token = AESGCMRequestStateCodec([_KEY_A]).seal(_PAYLOAD)
assert token.startswith(_TOKEN_PREFIX)
body = token.removeprefix(_TOKEN_PREFIX)
assert "=" not in body
assert set(body) <= _B64URL_ALPHABET
assert len(_decode_body(token)) == _KID_LEN + _NONCE_LEN + len(_PAYLOAD) + _GCM_TAG_LEN
def test_two_seals_of_the_same_payload_produce_distinct_tokens_that_both_unseal() -> None:
"""SDK-defined: every seal draws a fresh nonce, so identical payloads yield distinct tokens that both verify."""
codec = AESGCMRequestStateCodec([_KEY_A])
first = codec.seal(_PAYLOAD)
second = codec.seal(_PAYLOAD)
assert first != second
assert codec.unseal(first) == _PAYLOAD
assert codec.unseal(second) == _PAYLOAD
@pytest.mark.parametrize(
"corrupt",
[
pytest.param(_flip_prefix_char, id="prefix-char"),
pytest.param(_flip_kid_byte, id="kid-byte"),
pytest.param(_flip_nonce_byte, id="nonce-byte"),
pytest.param(_flip_ciphertext_byte, id="ciphertext-byte"),
pytest.param(_flip_tag_byte, id="tag-byte"),
],
)
def test_a_token_corrupted_in_any_region_is_rejected_without_echoing_the_payload(
corrupt: Callable[[str], str],
) -> None:
"""Spec-mandated (basic/patterns/mrtr, server requirement 4): any corrupted token region is rejected."""
codec = AESGCMRequestStateCodec([_KEY_A])
token = codec.seal(_PAYLOAD)
with pytest.raises(InvalidRequestState) as exc:
codec.unseal(corrupt(token))
message = str(exc.value)
assert len(message) <= _REASON_CODE_MAX_LEN
assert _PAYLOAD.decode() not in message
@pytest.mark.parametrize(
"token",
[
pytest.param("", id="empty-string"),
pytest.param(_b64u_nopad(b"\x00" * 64), id="missing-prefix"),
pytest.param(_TOKEN_PREFIX + "!!!not-base64!!!", id="garbage-after-prefix"),
pytest.param(_TOKEN_PREFIX + _b64u_nopad(b"\x00" * (_BODY_FLOOR - 1)), id="below-floor"),
],
)
def test_a_structurally_malformed_token_is_rejected(token: str) -> None:
"""Spec-mandated (basic/patterns/mrtr, server requirement 4): tokens this codec never minted fail."""
with pytest.raises(InvalidRequestState):
AESGCMRequestStateCodec([_KEY_A]).unseal(token)
def test_a_token_minted_under_a_key_outside_the_ring_is_rejected_as_unknown_key() -> None:
"""Spec-mandated (basic/patterns/mrtr, server requirement 4): a foreign-key token fails as "unknown key"."""
token = AESGCMRequestStateCodec([_KEY_A]).seal(_PAYLOAD)
with pytest.raises(InvalidRequestState) as exc:
AESGCMRequestStateCodec([_KEY_B]).unseal(token)
assert str(exc.value) == "unknown key"
@pytest.mark.parametrize(
"ring",
[
pytest.param([_KEY_OLD, _KEY_NEW], id="rotation-phase-1"),
pytest.param([_KEY_NEW, _KEY_OLD], id="rotation-phase-2"),
],
)
def test_a_token_minted_under_the_old_key_unseals_under_any_ring_containing_it(ring: list[bytes]) -> None:
"""SDK-defined rotation: every ring key verifies, so old-key state survives both rollout phases."""
token = AESGCMRequestStateCodec([_KEY_OLD]).seal(_PAYLOAD)
assert AESGCMRequestStateCodec(ring).unseal(token) == _PAYLOAD
def test_the_first_ring_key_mints_and_later_ring_keys_only_verify() -> None:
"""SDK-defined rotation: keys[0] is the minter, so [new, old] state verifies under [new] but not [old]."""
token = AESGCMRequestStateCodec([_KEY_NEW, _KEY_OLD]).seal(_PAYLOAD)
assert AESGCMRequestStateCodec([_KEY_NEW]).unseal(token) == _PAYLOAD
with pytest.raises(InvalidRequestState):
AESGCMRequestStateCodec([_KEY_OLD]).unseal(token)
def test_a_token_minted_under_a_retired_key_is_rejected() -> None:
"""Spec-mandated (basic/patterns/mrtr, server requirement 4): retired-key state fails verification."""
token = AESGCMRequestStateCodec([_KEY_OLD]).seal(_PAYLOAD)
with pytest.raises(InvalidRequestState):
AESGCMRequestStateCodec([_KEY_NEW]).unseal(token)
def test_an_empty_key_ring_is_rejected_at_construction() -> None:
"""SDK-defined: an empty ring is a configuration error caught at construction."""
with pytest.raises(ValueError) as exc:
AESGCMRequestStateCodec([])
assert str(exc.value) == snapshot("AESGCMRequestStateCodec requires at least one key")
def test_a_key_shorter_than_32_bytes_is_rejected_with_generation_guidance() -> None:
"""SDK-defined: keys must carry at least 32 bytes; the error includes generation guidance."""
with pytest.raises(ValueError) as exc:
AESGCMRequestStateCodec([b"k" * 31])
assert str(exc.value) == snapshot(
"request-state keys must be at least 32 bytes of secret randomness; keys[0] is 31 bytes. "
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
)
def test_a_duplicate_key_in_the_ring_is_rejected_at_construction() -> None:
"""SDK-defined: duplicate ring keys are a rotation mistake caught at construction."""
with pytest.raises(ValueError) as exc:
AESGCMRequestStateCodec([_KEY_A, _KEY_A])
assert str(exc.value) == snapshot("keys[1] duplicates an earlier ring key")
def test_a_non_key_typed_ring_entry_is_rejected_naming_its_index_and_type() -> None:
"""SDK-defined: a non-key ring entry raises a TypeError naming its index and type, in codec and policy."""
with pytest.raises(TypeError) as exc:
AESGCMRequestStateCodec([_KEY_A, cast("Any", 32)])
assert str(exc.value) == snapshot("request-state keys must be bytes, bytearray, or str; keys[1] is int")
with pytest.raises(TypeError) as exc:
RequestStateSecurity(keys=[cast("Any", 32)])
assert str(exc.value) == snapshot("request-state keys must be bytes, bytearray, or str; keys[0] is int")
def test_a_mixed_ring_of_bytes_bytearray_and_str_entries_still_works() -> None:
"""SDK-defined: bytes, bytearray, and str keys interoperate in one ring."""
codec = AESGCMRequestStateCodec([_KEY_A, bytearray(_KEY_B), "c" * 32])
assert codec.unseal(codec.seal(_PAYLOAD)) == _PAYLOAD
assert codec.unseal(AESGCMRequestStateCodec([bytearray(_KEY_B)]).seal(_PAYLOAD)) == _PAYLOAD
assert codec.unseal(AESGCMRequestStateCodec(["c" * 32]).seal(_PAYLOAD)) == _PAYLOAD
def test_a_str_key_is_equivalent_to_its_utf8_bytes_form() -> None:
"""SDK-defined: a str key is utf-8 encoded, so it is the same ring key as its bytes spelling."""
token = AESGCMRequestStateCodec(["k" * 32]).seal(_PAYLOAD)
assert AESGCMRequestStateCodec([b"k" * 32]).unseal(token) == _PAYLOAD
def test_bytearray_key_material_is_copied_at_construction() -> None:
"""SDK-defined: key bytes are copied at construction; mutating the caller's bytearray later has no effect."""
material = bytearray(b"m" * 32)
codec = AESGCMRequestStateCodec([cast("Any", material)])
minted_before_mutation = codec.seal(_PAYLOAD)
material[:] = b"X" * 32
assert codec.unseal(minted_before_mutation) == _PAYLOAD
assert AESGCMRequestStateCodec([b"m" * 32]).unseal(codec.seal(_PAYLOAD)) == _PAYLOAD
def test_the_token_reveals_the_payload_neither_in_its_text_nor_its_decoded_bytes() -> None:
"""SDK-defined: the token is encrypted, not merely signed, so the plaintext appears nowhere in it."""
token = AESGCMRequestStateCodec([_KEY_A]).seal(_PAYLOAD)
assert _PAYLOAD.decode() not in token
assert _b64u_nopad(_PAYLOAD) not in token
assert _PAYLOAD.hex() not in token
assert _PAYLOAD not in _decode_body(token)
def test_every_substitution_of_the_final_token_character_is_rejected() -> None:
"""Spec-mandated (basic/patterns/mrtr, server requirement 4): canonical decoding
rejects every final-character substitution despite base64 don't-care padding bits."""
codec = AESGCMRequestStateCodec([_KEY_A])
body = codec.seal(_PAYLOAD).removeprefix(_TOKEN_PREFIX)
substitutions = [c for c in sorted(_B64URL_ALPHABET) if c != body[-1]]
assert len(substitutions) == 63
for c in substitutions:
with pytest.raises(InvalidRequestState):
codec.unseal(_TOKEN_PREFIX + body[:-1] + c)
@pytest.mark.parametrize(
"mangle",
[
pytest.param(_inject_junk_chars, id="junk-chars-injected"),
pytest.param(_append_newline, id="newline-appended"),
pytest.param(_append_padding, id="padding-appended"),
],
)
def test_a_non_canonical_token_body_is_rejected(mangle: Callable[[str], str]) -> None:
"""Spec-mandated (basic/patterns/mrtr, server requirement 4): lax-decoder aliases of a token are rejected."""
codec = AESGCMRequestStateCodec([_KEY_A])
body = codec.seal(_PAYLOAD).removeprefix(_TOKEN_PREFIX)
with pytest.raises(InvalidRequestState):
codec.unseal(_TOKEN_PREFIX + mangle(body))
def test_a_token_reprefixed_to_a_future_format_version_is_rejected() -> None:
"""Spec-mandated (basic/patterns/mrtr, server requirement 4): the prefix is tag-bound; "v2." replay fails."""
codec = AESGCMRequestStateCodec([_KEY_A])
token = codec.seal(_PAYLOAD)
with pytest.raises(InvalidRequestState):
codec.unseal("v2." + token.removeprefix(_TOKEN_PREFIX))
def test_a_kid_transplanted_onto_another_tokens_body_is_rejected() -> None:
"""Spec-mandated (basic/patterns/mrtr, server requirement 4): the kid is tag-bound; transplanting it fails."""
raw_a = _decode_body(AESGCMRequestStateCodec([_KEY_A]).seal(_PAYLOAD))
raw_b = _decode_body(AESGCMRequestStateCodec([_KEY_B]).seal(_PAYLOAD))
assert raw_a[:_KID_LEN] != raw_b[:_KID_LEN]
transplanted = _TOKEN_PREFIX + _b64u_nopad(raw_a[:_KID_LEN] + raw_b[_KID_LEN:])
with pytest.raises(InvalidRequestState):
AESGCMRequestStateCodec([_KEY_A, _KEY_B]).unseal(transplanted)
# -- RequestStateSecurity -----------------------------------------------------
def test_keys_and_codec_together_are_rejected_at_policy_construction() -> None:
"""SDK-defined: keys= and codec= are mutually exclusive."""
with pytest.raises(ValueError) as exc:
RequestStateSecurity(keys=[_KEY_A], codec=_StaticCodec())
assert str(exc.value) == snapshot("RequestStateSecurity takes exactly one of keys= or codec=")
def test_a_policy_with_neither_keys_nor_codec_is_rejected() -> None:
"""SDK-defined: a policy must name its codec; an empty policy is a mistake, not a posture."""
with pytest.raises(ValueError) as exc:
RequestStateSecurity()
assert str(exc.value) == snapshot("RequestStateSecurity takes exactly one of keys= or codec=")
@pytest.mark.parametrize(
"ttl",
[
pytest.param(0.0, id="zero"),
pytest.param(-600.0, id="negative"),
pytest.param(float("nan"), id="nan"),
pytest.param(float("inf"), id="inf"),
],
)
def test_a_non_positive_or_non_finite_ttl_is_rejected_at_policy_construction(ttl: float) -> None:
"""SDK-defined: zero, negative, NaN, and infinite ttl fail at construction for keys and ephemeral() alike."""
with pytest.raises(ValueError, match="positive finite"):
RequestStateSecurity(keys=[_KEY_A], ttl=ttl)
with pytest.raises(ValueError, match="positive finite"):
RequestStateSecurity.ephemeral(ttl=ttl)
def test_keys_produce_a_working_built_in_codec_on_the_policy() -> None:
"""SDK-defined: keys=[...] builds the built-in AES-GCM codec, exposed on .codec."""
security = RequestStateSecurity(keys=[_KEY_A])
assert isinstance(security.codec, AESGCMRequestStateCodec)
assert security.codec.unseal(security.codec.seal(_PAYLOAD)) == _PAYLOAD
def test_a_custom_codec_is_stored_on_the_policy_as_is() -> None:
"""SDK-defined: codec=... stores the caller's object unwrapped."""
codec = _StaticCodec()
security = RequestStateSecurity(codec=codec)
assert security.codec is codec
assert codec.unseal(codec.seal(_PAYLOAD)) == _PAYLOAD
def test_ephemeral_policies_are_protected_and_mutually_unintelligible() -> None:
"""SDK-defined: ephemeral() protects under a process-local key, so a sibling instance rejects its tokens."""
first = RequestStateSecurity.ephemeral()
second = RequestStateSecurity.ephemeral()
token = first.codec.seal(_PAYLOAD)
assert first.codec.unseal(token) == _PAYLOAD
with pytest.raises(InvalidRequestState):
second.codec.unseal(token)
def test_the_policy_stores_an_explicit_audience_and_defaults_to_none() -> None:
"""SDK-defined: audience is stored as given; None defers to the server tier's `default_audience`."""
assert RequestStateSecurity(keys=[_KEY_A]).audience is None
assert RequestStateSecurity(keys=[_KEY_A], audience="svc").audience == "svc"
assert RequestStateSecurity.ephemeral(audience="svc").audience == "svc"
def test_the_default_principal_binding_is_authenticated_principal() -> None:
"""SDK-defined: an unconfigured policy binds state to the authenticated OAuth client by default."""
assert RequestStateSecurity(keys=[_KEY_A]).bind_principal is authenticated_principal
def test_an_explicit_principal_binding_callable_is_stored() -> None:
"""SDK-defined: a custom bind_principal callable is stored as given."""
def tenant_binding(ctx: ServerRequestContext[Any, Any]) -> str | None:
return "tenant-1"
security = RequestStateSecurity(keys=[_KEY_A], bind_principal=tenant_binding)
assert security.bind_principal is tenant_binding
assert tenant_binding(_bare_context()) == "tenant-1"
# -- authenticated_principal ----------------------------------------------------
def test_authenticated_principal_is_none_without_an_auth_context() -> None:
"""SDK-defined: without an auth context the default binding derives no principal."""
assert authenticated_principal(_bare_context()) is None
@pytest.mark.parametrize(
("token", "expected"),
[
pytest.param(
AccessToken(token="at-1", client_id="client-123", scopes=[]),
'["client-123",null,null]',
id="client-only",
),
pytest.param(
AccessToken(token="at-2", client_id="client-123", scopes=[], subject="alice"),
'["client-123",null,"alice"]',
id="with-subject",
),
pytest.param(
AccessToken(
token="at-3", client_id="client-123", scopes=[], subject="alice", claims={"iss": "https://as.example"}
),
'["client-123","https://as.example","alice"]',
id="with-issuer-and-subject",
),
],
)
def test_authenticated_principal_is_the_tokens_client_issuer_subject_identity(
token: AccessToken, expected: str
) -> None:
"""SDK-defined: the default binding composes (client_id, issuer, subject), degrading per component."""
reset = auth_context_var.set(AuthenticatedUser(token))
try:
assert authenticated_principal(_bare_context()) == expected
finally:
auth_context_var.reset(reset)
def test_authenticated_principal_distinguishes_two_subjects_of_one_client() -> None:
"""SDK-defined: two users of the same OAuth client are distinct principals when subjects are supplied."""
alice = AccessToken(token="at-a", client_id="https://agent.example/client.json", scopes=[], subject="alice")
bob = AccessToken(token="at-b", client_id="https://agent.example/client.json", scopes=[], subject="bob")
principals: list[str | None] = []
for token in (alice, bob):
reset = auth_context_var.set(AuthenticatedUser(token))
try:
principals.append(authenticated_principal(_bare_context()))
finally:
auth_context_var.reset(reset)
assert principals[0] != principals[1]
def test_authenticated_principal_uses_the_same_components_as_session_ownership() -> None:
"""SDK-defined: the binding and authorization_context derive from one principal_components source."""
token = AccessToken(
token="at-1", client_id="client-123", scopes=[], subject="alice", claims={"iss": "https://as.example"}
)
assert authorization_context(AuthenticatedUser(token)) == {
"client_id": "client-123",
"issuer": "https://as.example",
"subject": "alice",
}
assert list(principal_components(token)) == ["client-123", "https://as.example", "alice"]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
"""Tests for the server-side `Context`.
`Context` extends `BaseContext` (forwarding to a `DispatchContext`) with
`lifespan`, `connection`, and request-scoped `log`. End-to-end tested over
`DirectDispatcher`.
"""
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
import anyio
import pytest
from mcp.server.connection import Connection
from mcp.server.context import Context
from mcp.shared.dispatcher import DispatchContext
from mcp.shared.transport_context import TransportContext
from ..shared.conftest import direct_pair
from ..shared.test_dispatcher import Recorder, echo_handlers, running_pair
DCtx = DispatchContext[TransportContext]
@dataclass
class _Lifespan:
name: str
@pytest.mark.anyio
async def test_context_exposes_lifespan_and_connection_and_forwards_base_context():
captured: list[Context[_Lifespan]] = []
conn_holder: list[Connection] = []
async def server_on_request(dctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
ctx: Context[_Lifespan] = Context(dctx, lifespan=_Lifespan("app"), connection=conn_holder[0])
captured.append(ctx)
return {}
async with running_pair(direct_pair, server_on_request=server_on_request) as (client, server, *_):
conn_holder.append(Connection.for_loop(server, session_id="sess-1"))
with anyio.fail_after(5):
await client.send_raw_request("t", None)
ctx = captured[0]
assert ctx.lifespan.name == "app"
assert ctx.connection is conn_holder[0]
assert ctx.transport.kind == "direct"
assert ctx.can_send_request is True
assert ctx.session_id == "sess-1"
assert ctx.headers is None
@pytest.mark.anyio
async def test_context_log_sends_request_scoped_message_notification():
crec = Recorder()
_, c_notify = echo_handlers(crec)
async def server_on_request(dctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
ctx: Context[_Lifespan] = Context(dctx, lifespan=_Lifespan("app"), connection=Connection.for_loop(dctx))
await ctx.log("debug", "hello") # pyright: ignore[reportDeprecated]
return {}
async with running_pair(direct_pair, server_on_request=server_on_request, client_on_notify=c_notify) as (
client,
*_,
):
with anyio.fail_after(5):
await client.send_raw_request("t", None)
await crec.notified.wait()
method, params = crec.notifications[0]
assert method == "notifications/message"
assert params is not None and params["level"] == "debug" and params["data"] == "hello"
@pytest.mark.anyio
async def test_context_log_includes_logger_and_meta_when_supplied():
crec = Recorder()
_, c_notify = echo_handlers(crec)
async def server_on_request(dctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
ctx: Context[_Lifespan] = Context(dctx, lifespan=_Lifespan("app"), connection=Connection.for_loop(dctx))
await ctx.log("info", "x", logger="my.log", meta={"traceId": "t"}) # pyright: ignore[reportDeprecated]
return {}
async with running_pair(direct_pair, server_on_request=server_on_request, client_on_notify=c_notify) as (
client,
*_,
):
with anyio.fail_after(5):
await client.send_raw_request("t", None)
await crec.notified.wait()
_, params = crec.notifications[0]
assert params is not None
assert params["logger"] == "my.log"
assert params["_meta"] == {"traceId": "t"}
+256
View File
@@ -0,0 +1,256 @@
"""Tests for `ServerSession`.
`ServerSession` is a thin per-request proxy over two `Outbound` channels and a
`Connection`. Tested with stub outbounds so we can assert what reaches the wire
(method, params, `CallOptions`) and which channel it routed to, without standing
up a transport.
"""
from collections.abc import Mapping
from typing import Any
import mcp_types as types
import pytest
from mcp_types import (
ClientCapabilities,
Implementation,
SamplingCapability,
SamplingToolsCapability,
)
from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
from pydantic import ValidationError
from mcp.server.connection import Connection
from mcp.server.session import ServerSession
from mcp.shared.dispatcher import CallOptions
from mcp.shared.message import ServerMessageMetadata
class StubOutbound:
"""Records `send_raw_request` / `notify` / `progress` calls and returns a canned result.
Structurally a `DispatchContext[Any]` so it can stand in for the per-request channel.
"""
transport: Any = None
can_send_request: bool = True
request_id: Any = None
message_metadata: Any = None
cancel_requested: Any = None
def __init__(self, result: dict[str, Any] | None = None) -> None:
self.requests: list[tuple[str, Mapping[str, Any] | None, CallOptions | None]] = []
self.notifications: list[tuple[str, Mapping[str, Any] | None]] = []
self.progress_calls: list[tuple[float, float | None, str | None]] = []
self.result = result if result is not None else {}
async def send_raw_request(
self,
method: str,
params: Mapping[str, Any] | None,
opts: CallOptions | None = None,
) -> dict[str, Any]:
self.requests.append((method, params, opts))
return self.result
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
self.notifications.append((method, params))
async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
self.progress_calls.append((progress, total, message))
def _make_session(
outbound: StubOutbound,
*,
capabilities: ClientCapabilities | None = None,
protocol_version: str = LATEST_HANDSHAKE_VERSION,
) -> ServerSession:
"""Single-channel session: the stub is both request and standalone outbound."""
client_info = Implementation(name="c", version="0") if capabilities is not None else None
conn = Connection.from_envelope(protocol_version, client_info, capabilities, outbound=outbound)
return ServerSession(outbound, conn)
def _two_channel_session(request_ch: StubOutbound, standalone_ch: StubOutbound) -> ServerSession:
"""Distinct request/standalone outbounds so routing assertions can tell the channels apart."""
conn = Connection.from_envelope(LATEST_HANDSHAKE_VERSION, None, None, outbound=standalone_ch)
return ServerSession(request_ch, conn)
@pytest.mark.anyio
async def test_send_request_forwards_timeout_and_progress_callback_as_call_options():
outbound = StubOutbound(result={"roots": []})
session = _make_session(outbound)
async def on_progress(progress: float, total: float | None, message: str | None) -> None:
raise NotImplementedError
result = await session.send_request(
types.ListRootsRequest(),
types.ListRootsResult,
request_read_timeout_seconds=2.5,
progress_callback=on_progress,
)
assert isinstance(result, types.ListRootsResult)
method, _params, opts = outbound.requests[0]
assert method == "roots/list"
assert opts == {"timeout": 2.5, "on_progress": on_progress}
@pytest.mark.anyio
async def test_send_request_omits_call_options_when_none_given():
outbound = StubOutbound(result={"roots": []})
session = _make_session(outbound)
await session.send_request(types.ListRootsRequest(), types.ListRootsResult)
_method, _params, opts = outbound.requests[0]
assert opts is None
@pytest.mark.anyio
async def test_send_request_timeout_zero_is_forwarded():
"""0 is a real timeout (fail at the first checkpoint, `anyio.fail_after(0)`
semantics) and must reach the channel; only `None` means "no timeout"."""
outbound = StubOutbound(result={})
session = _make_session(outbound)
await session.send_request(types.PingRequest(), types.EmptyResult, request_read_timeout_seconds=0.0)
assert outbound.requests[0][2] == {"timeout": 0.0}
@pytest.mark.anyio
async def test_send_request_without_related_id_routes_to_standalone_channel():
"""SDK-defined: no `related_request_id` routes the request onto the connection's standalone channel."""
request_ch = StubOutbound()
standalone_ch = StubOutbound(result={"roots": []})
session = _two_channel_session(request_ch, standalone_ch)
await session.send_request(types.ListRootsRequest(), types.ListRootsResult)
assert request_ch.requests == []
assert standalone_ch.requests[0][0] == "roots/list"
@pytest.mark.anyio
async def test_send_request_with_related_id_routes_to_request_channel():
"""SDK-defined: with `related_request_id` the request rides the per-request channel
(the originating POST's response stream over streamable HTTP)."""
request_ch = StubOutbound(result={"action": "cancel"})
standalone_ch = StubOutbound()
session = _two_channel_session(request_ch, standalone_ch)
result = await session.send_request(
types.ElicitRequest(params=types.ElicitRequestFormParams(message="q", requested_schema={})),
types.ElicitResult,
metadata=ServerMessageMetadata(related_request_id=7),
)
assert isinstance(result, types.ElicitResult)
assert standalone_ch.requests == []
assert request_ch.requests[0][0] == "elicitation/create"
@pytest.mark.anyio
async def test_send_notification_routes_by_related_request_id():
"""SDK-defined: notifications select channel by `related_request_id` exactly like requests."""
request_ch = StubOutbound()
standalone_ch = StubOutbound()
session = _two_channel_session(request_ch, standalone_ch)
await session.send_tool_list_changed()
await session.send_progress_notification("tok", 0.5, related_request_id="req-1")
assert [m for m, _ in standalone_ch.notifications] == ["notifications/tools/list_changed"]
assert [m for m, _ in request_ch.notifications] == ["notifications/progress"]
@pytest.mark.anyio
async def test_report_progress_delegates_to_the_request_dispatch_context():
"""`report_progress` calls the per-request `DispatchContext.progress` seam, never the
standalone channel: token gating and routing live in the dispatcher, not here."""
request_ch = StubOutbound()
standalone_ch = StubOutbound()
session = _two_channel_session(request_ch, standalone_ch)
await session.report_progress(0.5, total=1.0, message="halfway")
assert request_ch.progress_calls == [(0.5, 1.0, "halfway")]
assert standalone_ch.progress_calls == []
assert request_ch.notifications == []
@pytest.mark.anyio
async def test_send_request_validates_the_client_result_against_the_surface_schema():
"""A spec-method result that fails the per-version surface schema raises
`ValidationError` even when the caller's `result_type` would accept it."""
session = _make_session(StubOutbound(result={"roots": "nope"}))
with pytest.raises(ValidationError):
await session.send_request(types.ListRootsRequest(), types.EmptyResult)
@pytest.mark.anyio
async def test_send_request_passes_a_spec_valid_client_result():
"""A spec-valid client result passes the surface gate and parses to the typed model."""
session = _make_session(StubOutbound(result={"roots": [{"uri": "file:///ws"}]}))
result = await session.send_request(types.ListRootsRequest(), types.ListRootsResult)
assert isinstance(result, types.ListRootsResult)
assert str(result.roots[0].uri) == "file:///ws"
@pytest.mark.anyio
async def test_send_request_skips_the_surface_gate_when_method_absent_at_version():
"""Surface row absent for the connection's version: gate is bypassed and only
`result_type` validates."""
session = _make_session(StubOutbound(result={}), protocol_version=LATEST_MODERN_VERSION)
result = await session.send_request(types.PingRequest(), types.EmptyResult)
assert isinstance(result, types.EmptyResult)
@pytest.mark.anyio
async def test_send_request_validates_result_alias_only():
"""Peer results validate alias-only; a snake_case key from the wire is
ignored as extra, not populated by Python field name."""
snake = {"role": "assistant", "content": {"type": "text", "text": "x"}, "model": "m", "stop_reason": "endTurn"}
session = _make_session(StubOutbound(result=snake))
request = types.CreateMessageRequest(params=types.CreateMessageRequestParams(messages=[], max_tokens=1))
result = await session.send_request(request, types.CreateMessageResult)
assert result.stop_reason is None
@pytest.mark.anyio
async def test_create_message_with_tools_returns_with_tools_result():
outbound = StubOutbound(result={"role": "assistant", "content": [{"type": "text", "text": "ok"}], "model": "m"})
session = _make_session(
outbound, capabilities=ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability()))
)
result = await session.create_message( # pyright: ignore[reportDeprecated]
messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hi"))],
max_tokens=10,
tools=[types.Tool(name="t", input_schema={"type": "object"})],
)
assert isinstance(result, types.CreateMessageResultWithTools)
method, params, _opts = outbound.requests[0]
assert method == "sampling/createMessage"
assert params is not None and params["tools"][0]["name"] == "t"
@pytest.mark.anyio
async def test_create_message_with_tool_choice_only_returns_with_tools_result():
# tool_choice alone is tools-mode: the answer may carry array content.
outbound = StubOutbound(result={"role": "assistant", "content": [{"type": "text", "text": "ok"}], "model": "m"})
session = _make_session(
outbound, capabilities=ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability()))
)
result = await session.create_message( # pyright: ignore[reportDeprecated]
messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hi"))],
max_tokens=10,
tool_choice=types.ToolChoice(mode="none"),
)
assert isinstance(result, types.CreateMessageResultWithTools)
def test_check_client_capability_delegates_to_connection():
outbound = StubOutbound()
session = _make_session(outbound, capabilities=ClientCapabilities(sampling=SamplingCapability()))
assert session.check_client_capability(ClientCapabilities(sampling=SamplingCapability())) is True
assert session.check_client_capability(ClientCapabilities(experimental={"x": {}})) is False
def test_protocol_version_proxies_connection():
"""SDK-defined: `session.protocol_version` reads through to the held `Connection`."""
_ARBITRARY_VERSION = "sentinel-version" # identity-only: any string the connection holds
conn = Connection.from_envelope(_ARBITRARY_VERSION, None, None)
session = ServerSession(StubOutbound(), conn)
assert session.protocol_version == _ARBITRARY_VERSION
assert session.client_params is None
+464
View File
@@ -0,0 +1,464 @@
"""Tests for SSE server request validation."""
import logging
import re
import anyio
import httpx
import pytest
import sse_starlette.sse
from mcp_types import JSONRPCRequest, JSONRPCResponse
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import Response
from starlette.routing import Mount, Route
from starlette.types import Message, Receive, Scope, Send
from mcp.server import Server
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
from mcp.server.auth.provider import AccessToken
from mcp.server.sse import SseServerTransport
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared._stream_protocols import WriteStream
from mcp.shared.message import SessionMessage
from tests.interaction.transports import StreamingASGITransport
logger = logging.getLogger(__name__)
SERVER_NAME = "test_sse_security_server"
# The in-process app is mounted at this origin purely so URLs are well-formed and the default
# Host header is a localhost form; nothing listens here.
BASE_URL = "http://127.0.0.1:8000"
@pytest.fixture(autouse=True)
def reset_sse_starlette_exit_event() -> None:
"""sse-starlette<2 caches a module-level anyio.Event on AppStatus; reset it
between tests so it is not bound to a previous test's event loop."""
app_status = getattr(sse_starlette.sse, "AppStatus", None)
if app_status is not None and hasattr(app_status, "should_exit_event"): # pragma: lax no cover
app_status.should_exit_event = None
def sse_security_client(security_settings: TransportSecuritySettings | None = None) -> httpx.AsyncClient:
"""An httpx client whose requests are served in process by an SSE app with the given settings."""
server = Server(SERVER_NAME)
sse_transport = SseServerTransport("/messages/", security_settings)
async def handle_sse(request: Request) -> Response:
try:
async with sse_transport.connect_sse(request.scope, request.receive, request._send) as (read, write):
await server.run(read, write, server.create_initialization_options())
except ValueError as e:
# Validation error was already handled inside connect_sse, which sent the rejection
# response itself; its non-empty body checkpoints, so the test reads the rejection
# status before the trailing Response() below sends a second response start.
logger.debug(f"SSE connection failed validation: {e}")
return Response()
app = Starlette(
routes=[
Route("/sse", endpoint=handle_sse),
Mount("/messages/", app=sse_transport.handle_post_message),
]
)
# The SSE GET runs until it observes a disconnect, so the bridge must let the application
# drain on close rather than cancelling it.
transport = StreamingASGITransport(app, cancel_on_close=False)
return httpx.AsyncClient(transport=transport, base_url=BASE_URL)
@pytest.mark.anyio
async def test_sse_security_default_settings() -> None:
"""With default security settings (protection disabled), any Host and Origin connect."""
headers = {"Host": "evil.com", "Origin": "http://evil.com"}
async with sse_security_client() as client:
async with client.stream("GET", "/sse", headers=headers) as response:
assert response.status_code == 200
@pytest.mark.anyio
async def test_sse_security_invalid_host_header() -> None:
"""A Host header outside allowed_hosts is rejected with 421."""
security_settings = TransportSecuritySettings(enable_dns_rebinding_protection=True, allowed_hosts=["example.com"])
async with sse_security_client(security_settings) as client:
response = await client.get("/sse", headers={"Host": "evil.com"})
assert response.status_code == 421
assert response.text == "Invalid Host header"
@pytest.mark.anyio
async def test_sse_security_invalid_origin_header() -> None:
"""An Origin header outside allowed_origins is rejected with 403."""
security_settings = TransportSecuritySettings(
enable_dns_rebinding_protection=True, allowed_hosts=["127.0.0.1:*"], allowed_origins=["http://localhost:*"]
)
async with sse_security_client(security_settings) as client:
response = await client.get("/sse", headers={"Origin": "http://evil.com"})
assert response.status_code == 403
assert response.text == "Invalid Origin header"
@pytest.mark.anyio
async def test_sse_security_post_invalid_content_type() -> None:
"""A POST whose Content-Type is not application/json (or is missing) is rejected with 400."""
security_settings = TransportSecuritySettings(
enable_dns_rebinding_protection=True, allowed_hosts=["127.0.0.1:*"], allowed_origins=["http://127.0.0.1:*"]
)
fake_session_id = "12345678123456781234567812345678"
async with sse_security_client(security_settings) as client:
response = await client.post(
f"/messages/?session_id={fake_session_id}",
headers={"Content-Type": "text/plain"},
content="test",
)
assert response.status_code == 400
assert response.text == "Invalid Content-Type header"
response = await client.post(f"/messages/?session_id={fake_session_id}", content="test")
assert response.status_code == 400
assert response.text == "Invalid Content-Type header"
@pytest.mark.anyio
async def test_sse_security_disabled() -> None:
"""With protection explicitly disabled, a disallowed Host still connects."""
settings = TransportSecuritySettings(enable_dns_rebinding_protection=False)
async with sse_security_client(settings) as client:
async with client.stream("GET", "/sse", headers={"Host": "evil.com"}) as response:
assert response.status_code == 200
@pytest.mark.anyio
async def test_sse_security_custom_allowed_hosts() -> None:
"""A custom entry in allowed_hosts connects; hosts outside the list are still rejected."""
settings = TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=["localhost", "127.0.0.1", "custom.host"],
allowed_origins=["http://localhost", "http://127.0.0.1", "http://custom.host"],
)
async with sse_security_client(settings) as client:
async with client.stream("GET", "/sse", headers={"Host": "custom.host"}) as response:
assert response.status_code == 200
response = await client.get("/sse", headers={"Host": "evil.com"})
assert response.status_code == 421
assert response.text == "Invalid Host header"
@pytest.mark.anyio
async def test_sse_security_wildcard_ports() -> None:
"""A `host:*` pattern accepts that host with any port, for Host and Origin alike."""
settings = TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=["localhost:*", "127.0.0.1:*"],
allowed_origins=["http://localhost:*", "http://127.0.0.1:*"],
)
async with sse_security_client(settings) as client:
for test_port in [8080, 3000, 9999]:
async with client.stream("GET", "/sse", headers={"Host": f"localhost:{test_port}"}) as response:
assert response.status_code == 200
async with client.stream("GET", "/sse", headers={"Origin": f"http://localhost:{test_port}"}) as response:
assert response.status_code == 200
@pytest.mark.anyio
async def test_sse_security_post_valid_content_type() -> None:
"""Every application/json Content-Type variant passes validation (reaching the session lookup)."""
security_settings = TransportSecuritySettings(
enable_dns_rebinding_protection=True, allowed_hosts=["127.0.0.1:*"], allowed_origins=["http://127.0.0.1:*"]
)
valid_content_types = [
"application/json",
"application/json; charset=utf-8",
"application/json;charset=utf-8",
"APPLICATION/JSON", # Case insensitive
]
# A well-formed session ID that no live session owns.
fake_session_id = "12345678123456781234567812345678"
async with sse_security_client(security_settings) as client:
for content_type in valid_content_types:
response = await client.post(
f"/messages/?session_id={fake_session_id}",
headers={"Content-Type": content_type},
json={"test": "data"},
)
# 404 proves the request passed the content-type check and reached the session lookup.
assert response.status_code == 404
assert response.text == "Could not find session"
def _authenticated_user(client_id: str, subject: str | None = None, issuer: str | None = None) -> AuthenticatedUser:
"""Build the scope["user"] value that AuthenticationMiddleware would set for this principal."""
claims = {"iss": issuer} if issuer is not None else None
return AuthenticatedUser(AccessToken(token="token", client_id=client_id, scopes=[], subject=subject, claims=claims))
def _sse_scope(
method: str, path: str, user: AuthenticatedUser | None, *, query_string: bytes = b"", body: bytes = b""
) -> tuple[Scope, Receive, Send, list[Message]]:
"""Build an ASGI scope/receive/send triple for a request to the SSE transport."""
scope: Scope = {
"type": "http",
"method": method,
"path": path,
"root_path": "",
"query_string": query_string,
"headers": [(b"content-type", b"application/json")],
}
if user is not None:
scope["user"] = user
sent: list[Message] = []
async def receive() -> Message:
return {"type": "http.request", "body": body, "more_body": False}
async def send(message: Message) -> None:
sent.append(message)
return scope, receive, send, sent
def _response_status(sent: list[Message]) -> int:
response_start = next(msg for msg in sent if msg["type"] == "http.response.start")
return response_start["status"]
async def _post_message(transport: SseServerTransport, session_id: str, user: AuthenticatedUser | None) -> int:
"""POST a message to an SSE session as `user` and return the response status."""
body = b'{"jsonrpc": "2.0", "id": 1, "method": "ping", "params": null}'
scope, receive, send, sent = _sse_scope(
"POST", "/messages/", user, query_string=f"session_id={session_id}".encode(), body=body
)
await transport.handle_post_message(scope, receive, send)
return _response_status(sent)
_Principal = tuple[str] | tuple[str, str] | tuple[str, str, str]
@pytest.mark.anyio
@pytest.mark.parametrize(
("creator", "sender", "expected"),
[
pytest.param(("client-a",), ("client-b",), 404, id="different-client"),
pytest.param(("client-a",), None, 404, id="unauthenticated-sender"),
pytest.param(("client-a", "alice"), ("client-a", "bob"), 404, id="same-client-different-subject"),
pytest.param(("client-a", "alice"), ("client-a",), 404, id="same-client-no-subject"),
pytest.param(
("client-a", "alice", "https://i1"), ("client-a", "alice", "https://i2"), 404, id="different-issuer"
),
pytest.param(None, ("client-a",), 404, id="unauthenticated-creator"),
pytest.param(("client-a",), ("client-a",), 202, id="same-client"),
pytest.param(("client-a", "alice"), ("client-a", "alice"), 202, id="same-client-and-subject"),
pytest.param(None, None, 202, id="both-unauthenticated"),
],
)
async def test_sse_post_requires_the_credential_that_created_the_session(
creator: _Principal | None,
sender: _Principal | None,
expected: int,
):
"""The session endpoint URL issued to one authenticated principal must not
accept messages from a request authenticated as a different one."""
transport = SseServerTransport("/messages/")
session_id_received = anyio.Event()
session_ids: list[str] = []
client_disconnected = anyio.Event()
async def get_send(message: Message) -> None:
# The first body chunk is the SSE event announcing the session URI to POST messages to.
if message["type"] == "http.response.body" and not session_ids:
match = re.search(rb"session_id=([0-9a-f]{32})", message.get("body", b""))
assert match is not None, f"expected the endpoint event first, got {message!r}"
session_ids.append(match.group(1).decode())
session_id_received.set()
async def get_receive() -> Message:
# The SSE client stays connected until the test signals otherwise.
await client_disconnected.wait()
return {"type": "http.disconnect"}
creator_user = _authenticated_user(*creator) if creator is not None else None
sender_user = _authenticated_user(*sender) if sender is not None else None
async def hold_sse_connection() -> None:
"""Establish the SSE session as `creator` and keep it open, as a server would."""
scope, _, _, _ = _sse_scope("GET", "/sse", creator_user)
with anyio.fail_after(5):
async with transport.connect_sse(scope, get_receive, get_send) as (read_stream, write_stream):
async with read_stream, write_stream: # pragma: no branch
async for _ in read_stream:
pass
async with anyio.create_task_group() as tg:
tg.start_soon(hold_sse_connection)
with anyio.fail_after(5):
await session_id_received.wait()
assert await _post_message(transport, session_ids[0], sender_user) == expected
client_disconnected.set()
# Once the connection is gone the session is no longer routable.
assert await _post_message(transport, session_ids[0], creator_user) == 404
@pytest.mark.anyio
async def test_sse_connect_rejects_a_non_http_scope():
"""connect_sse refuses ASGI scopes that are not HTTP requests."""
transport = SseServerTransport("/messages/")
with pytest.raises(ValueError):
async with transport.connect_sse({"type": "websocket"}, _no_receive, _no_send):
raise NotImplementedError
@pytest.mark.anyio
async def test_sse_connect_rejects_a_disallowed_host():
"""connect_sse rejects requests whose Host header fails the configured security check."""
settings = TransportSecuritySettings(allowed_hosts=["allowed.example.com"])
transport = SseServerTransport("/messages/", security_settings=settings)
scope, receive, send, sent = _sse_scope("GET", "/sse", None)
scope["headers"] = [(b"host", b"disallowed.example.com")]
with pytest.raises(ValueError):
async with transport.connect_sse(scope, receive, send):
raise NotImplementedError
assert _response_status(sent) == 421
@pytest.mark.anyio
async def test_sse_post_without_a_session_id_returns_400():
"""POSTs to the messages endpoint must include a session_id query parameter."""
transport = SseServerTransport("/messages/")
scope, receive, send, sent = _sse_scope("POST", "/messages/", None)
await transport.handle_post_message(scope, receive, send)
assert _response_status(sent) == 400
@pytest.mark.anyio
async def test_sse_post_with_a_malformed_session_id_returns_400():
"""A session_id that is not 32 hex characters is rejected before any session lookup."""
transport = SseServerTransport("/messages/")
scope, receive, send, sent = _sse_scope("POST", "/messages/", None, query_string=b"session_id=not-hex")
await transport.handle_post_message(scope, receive, send)
assert _response_status(sent) == 400
@pytest.mark.anyio
async def test_sse_post_with_a_disallowed_host_is_rejected_before_session_lookup():
"""The transport security check on POST runs before any session-ID handling."""
settings = TransportSecuritySettings(allowed_hosts=["allowed.example.com"])
transport = SseServerTransport("/messages/", security_settings=settings)
scope, receive, send, sent = _sse_scope("POST", "/messages/", None)
scope["headers"] = [(b"host", b"disallowed.example.com"), (b"content-type", b"application/json")]
await transport.handle_post_message(scope, receive, send)
assert _response_status(sent) == 421
@pytest.mark.anyio
async def test_sse_round_trip_delivers_posted_messages_and_streams_responses():
"""A POSTed JSON-RPC message reaches the server's read stream, and a message
written to the server's write stream is sent to the client as an SSE event."""
transport = SseServerTransport("/messages/")
session = _SseSession(transport)
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
tg.start_soon(session.hold)
await session.ready.wait()
# POST a parse-failing body: client gets 400, server's read stream receives the error.
scope, receive, send, sent = _sse_scope(
"POST", "/messages/", None, query_string=f"session_id={session.session_id}".encode(), body=b"not json"
)
await transport.handle_post_message(scope, receive, send)
assert _response_status(sent) == 400
assert isinstance(await session.next_read_item(), Exception)
# POST a valid message: client gets 202, server's read stream receives it.
assert await _post_message(transport, session.session_id, None) == 202
received = await session.next_read_item()
assert isinstance(received, SessionMessage)
assert isinstance(received.message, JSONRPCRequest)
assert received.message.method == "ping"
# Server writes a response: it appears as an SSE `message` event on the GET stream.
outgoing = JSONRPCResponse(jsonrpc="2.0", id=1, result={})
await session.write_stream.send(SessionMessage(outgoing))
chunk = await session.next_body_chunk()
assert b"event: message" in chunk
assert outgoing.model_dump_json(by_alias=True, exclude_unset=True).encode() in chunk
session.disconnect()
class _SseSession:
"""Drive an in-process SSE GET connection and surface what the server reads and the client receives.
`hold` runs the connection in a background task and consumes the server-side read stream
into a buffer so that `handle_post_message` (which writes to that stream with a zero-capacity
channel) never blocks the test body.
"""
def __init__(self, transport: SseServerTransport) -> None:
self.transport = transport
self.ready = anyio.Event()
self._disconnected = anyio.Event()
self._body_send, self._body_recv = anyio.create_memory_object_stream[bytes](16)
self._read_send, self._read_recv = anyio.create_memory_object_stream[SessionMessage | Exception](16)
self.session_id = ""
self.write_stream: WriteStream[SessionMessage]
async def hold(self) -> None:
scope, _, _, _ = _sse_scope("GET", "/sse", None)
async with self.transport.connect_sse(scope, self._receive, self._send) as (read, write):
self.write_stream = write
async with read, write, self._body_send, self._body_recv, self._read_send, self._read_recv:
async for item in read:
await self._read_send.send(item)
def disconnect(self) -> None:
self._disconnected.set()
async def next_read_item(self) -> SessionMessage | Exception:
return await self._read_recv.receive()
async def next_body_chunk(self) -> bytes:
return await self._body_recv.receive()
async def _receive(self) -> Message:
await self._disconnected.wait()
return {"type": "http.disconnect"}
async def _send(self, message: Message) -> None:
if message["type"] != "http.response.body":
return
body: bytes = message.get("body", b"")
if not self.session_id:
match = re.search(rb"session_id=([0-9a-f]{32})", body)
assert match is not None, f"expected the endpoint event first, got {message!r}"
self.session_id = match.group(1).decode()
self.ready.set()
else:
await self._body_send.send(body)
async def _no_receive() -> Message:
raise NotImplementedError
async def _no_send(message: Message) -> None:
raise NotImplementedError
+182
View File
@@ -0,0 +1,182 @@
"""Tests for the no-back-channel path (stateless HTTP).
A `Connection.from_envelope(...)` connection installs the no-channel sentinel
as its standalone outbound, so server-to-client requests with no related
request to ride on raise `NoBackChannelError` from the channel itself.
See: https://github.com/modelcontextprotocol/python-sdk/issues/1097
"""
from collections.abc import Mapping
from typing import Any
import mcp_types as types
import pytest
from mcp_types import LATEST_PROTOCOL_VERSION
from mcp.server.connection import Connection
from mcp.server.session import ServerSession
from mcp.shared.dispatcher import CallOptions
from mcp.shared.exceptions import NoBackChannelError
class StubOutbound:
"""Records `send_raw_request` / `notify` calls and returns a canned result.
Structurally a `DispatchContext[Any]` so it can stand in for the per-request channel.
"""
transport: Any = None
can_send_request: bool = True
request_id: Any = None
message_metadata: Any = None
cancel_requested: Any = None
def __init__(self, result: dict[str, Any] | None = None) -> None:
self.requests: list[tuple[str, Mapping[str, Any] | None, CallOptions | None]] = []
self.notifications: list[tuple[str, Mapping[str, Any] | None]] = []
self.result = result if result is not None else {}
async def send_raw_request(
self,
method: str,
params: Mapping[str, Any] | None,
opts: CallOptions | None = None,
) -> dict[str, Any]:
self.requests.append((method, params, opts))
return self.result
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
self.notifications.append((method, params))
async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
raise NotImplementedError # pragma: no cover
def _no_channel_session(request_ch: StubOutbound | None = None) -> tuple[ServerSession, StubOutbound]:
"""A session whose standalone channel is the connection's no-channel
sentinel; the request channel is a working stub."""
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
assert conn.has_standalone_channel is False
request = request_ch if request_ch is not None else StubOutbound()
return ServerSession(request, conn), request
@pytest.fixture
def no_channel_session() -> ServerSession:
session, _ = _no_channel_session()
return session
@pytest.mark.anyio
async def test_list_roots_raises_no_back_channel(no_channel_session: ServerSession):
"""SDK-defined: `list_roots` has no `related_request_id` so it always rides
the standalone channel, which raises here."""
with pytest.raises(NoBackChannelError) as exc:
await no_channel_session.list_roots() # pyright: ignore[reportDeprecated]
assert exc.value.method == "roots/list"
@pytest.mark.anyio
async def test_send_ping_raises_no_back_channel(no_channel_session: ServerSession):
"""SDK-defined: `send_ping` rides the standalone channel and raises when there is none."""
with pytest.raises(NoBackChannelError) as exc:
await no_channel_session.send_ping()
assert exc.value.method == "ping"
@pytest.mark.anyio
async def test_create_message_raises_no_back_channel_without_related_id(no_channel_session: ServerSession):
"""SDK-defined: `create_message` without a related id rides the standalone channel and raises."""
with pytest.raises(NoBackChannelError) as exc:
await no_channel_session.create_message( # pyright: ignore[reportDeprecated]
messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hi"))],
max_tokens=100,
)
assert exc.value.method == "sampling/createMessage"
@pytest.mark.anyio
async def test_elicit_form_raises_no_back_channel_without_related_id(no_channel_session: ServerSession):
"""SDK-defined: `elicit_form` without a related id rides the standalone channel and raises."""
with pytest.raises(NoBackChannelError) as exc:
await no_channel_session.elicit_form(message="m", requested_schema={"type": "object", "properties": {}})
assert exc.value.method == "elicitation/create"
@pytest.mark.anyio
async def test_elicit_url_raises_no_back_channel_without_related_id(no_channel_session: ServerSession):
"""SDK-defined: `elicit_url` without a related id rides the standalone channel and raises."""
with pytest.raises(NoBackChannelError) as exc:
await no_channel_session.elicit_url(message="m", url="https://example.com/auth", elicitation_id="e-1")
assert exc.value.method == "elicitation/create"
@pytest.mark.anyio
async def test_elicit_deprecated_raises_no_back_channel_without_related_id(no_channel_session: ServerSession):
"""SDK-defined: the deprecated `elicit` alias routes the same as `elicit_form` and raises."""
with pytest.raises(NoBackChannelError) as exc:
await no_channel_session.elicit(message="m", requested_schema={"type": "object", "properties": {}})
assert exc.value.method == "elicitation/create"
@pytest.mark.anyio
async def test_send_request_raises_no_back_channel_without_related_id(no_channel_session: ServerSession):
"""SDK-defined: the generic `send_request` path with no metadata routes standalone and raises."""
with pytest.raises(NoBackChannelError) as exc:
await no_channel_session.send_request(types.ListRootsRequest(), types.ListRootsResult)
assert exc.value.method == "roots/list"
@pytest.mark.anyio
async def test_elicit_form_with_related_id_rides_the_request_channel():
"""SDK-defined: with a related request the message rides the per-request
channel, so the no-channel standalone is never touched and the call succeeds."""
session, request_ch = _no_channel_session(StubOutbound(result={"action": "cancel"}))
result = await session.elicit_form(
message="m", requested_schema={"type": "object", "properties": {}}, related_request_id=3
)
assert isinstance(result, types.ElicitResult)
assert request_ch.requests[0][0] == "elicitation/create"
@pytest.mark.anyio
async def test_send_log_message_with_related_id_rides_the_request_channel():
"""SDK-defined: the deprecated ``send_log_message`` notification with a related id
rides the per-request channel, so it is delivered even with no standalone back-channel."""
session, request_ch = _no_channel_session()
await session.send_log_message( # pyright: ignore[reportDeprecated]
level="info", data="hello", logger="test", related_request_id=3
)
assert request_ch.notifications == [("notifications/message", {"level": "info", "data": "hello", "logger": "test"})]
@pytest.mark.anyio
async def test_unrelated_notification_is_dropped_silently():
"""SDK-defined: notifications on the no-channel standalone are best-effort — dropped, never raised."""
session, request_ch = _no_channel_session()
await session.send_tool_list_changed()
assert request_ch.notifications == []
@pytest.mark.anyio
async def test_loop_connection_outbound_does_not_raise_no_back_channel():
"""SDK-defined: a `for_loop` connection holds a real outbound, so the
standalone path reaches the channel rather than raising."""
standalone = StubOutbound(result={"roots": []})
conn = Connection.for_loop(standalone)
assert conn.has_standalone_channel is True
session = ServerSession(StubOutbound(), conn)
result = await session.list_roots() # pyright: ignore[reportDeprecated]
assert isinstance(result, types.ListRootsResult)
assert standalone.requests[0][0] == "roots/list"
@pytest.mark.anyio
async def test_from_envelope_connection_ping_raises_no_back_channel():
"""SDK-defined: `Connection`'s own helpers route through the same sentinel,
so `ping` on a `from_envelope` connection raises."""
conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None)
with pytest.raises(NoBackChannelError) as exc:
await conn.ping()
assert exc.value.method == "ping"
+276
View File
@@ -0,0 +1,276 @@
import io
import sys
import threading
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from io import TextIOWrapper
import anyio
import pytest
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
PROTOCOL_VERSION_META_KEY,
JSONRPCMessage,
JSONRPCRequest,
JSONRPCResponse,
jsonrpc_message_adapter,
)
from typing_extensions import Buffer
from mcp.server.mcpserver import MCPServer
from mcp.server.stdio import stdio_server
from mcp.shared.message import SessionMessage
@pytest.mark.anyio
async def test_stdio_server_round_trips_messages_over_injected_streams() -> None:
"""stdio_server frames JSON-RPC messages as one line each in both directions.
Parses one message per stdin line and writes each outgoing message as exactly one
line, driven over injected in-process streams.
"""
stdin = io.StringIO()
stdout = io.StringIO()
messages = [
JSONRPCRequest(jsonrpc="2.0", id=1, method="ping"),
JSONRPCResponse(jsonrpc="2.0", id=2, result={}),
]
for message in messages:
stdin.write(message.model_dump_json(by_alias=True, exclude_none=True) + "\n")
stdin.seek(0)
with anyio.fail_after(5):
async with stdio_server(stdin=anyio.AsyncFile(stdin), stdout=anyio.AsyncFile(stdout)) as (
read_stream,
write_stream,
):
async with read_stream:
received_messages: list[JSONRPCMessage] = []
for _ in range(2):
received = await read_stream.receive()
assert not isinstance(received, Exception)
received_messages.append(received.message)
assert received_messages[0] == JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
assert received_messages[1] == JSONRPCResponse(jsonrpc="2.0", id=2, result={})
responses = [
JSONRPCRequest(jsonrpc="2.0", id=3, method="ping"),
JSONRPCResponse(jsonrpc="2.0", id=4, result={}),
]
for response in responses:
await write_stream.send(SessionMessage(response))
await write_stream.aclose()
stdout.seek(0)
output_lines = stdout.readlines()
assert len(output_lines) == 2
received_responses = [jsonrpc_message_adapter.validate_json(line.strip()) for line in output_lines]
assert received_responses[0] == JSONRPCRequest(jsonrpc="2.0", id=3, method="ping")
assert received_responses[1] == JSONRPCResponse(jsonrpc="2.0", id=4, result={})
@pytest.mark.anyio
async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch) -> None:
"""Non-UTF-8 stdin bytes surface as an in-stream exception without killing the stream.
Invalid bytes are replaced with U+FFFD, fail JSON parsing, and arrive as an in-stream
exception; subsequent valid messages are still processed.
"""
# \xff\xfe are invalid UTF-8 start bytes.
valid = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
raw_stdin = io.BytesIO(b"\xff\xfe\n" + valid.model_dump_json(by_alias=True, exclude_none=True).encode() + b"\n")
# Replace sys.stdin with a wrapper whose .buffer is our raw bytes, so that
# stdio_server()'s default path wraps it with errors='replace'.
monkeypatch.setattr(sys, "stdin", TextIOWrapper(raw_stdin, encoding="utf-8"))
monkeypatch.setattr(sys, "stdout", TextIOWrapper(io.BytesIO(), encoding="utf-8"))
with anyio.fail_after(5):
async with stdio_server() as (read_stream, write_stream):
await write_stream.aclose()
async with read_stream: # pragma: no branch
# First line: \xff\xfe -> U+FFFD U+FFFD -> JSON parse fails -> exception in stream
first = await read_stream.receive()
assert isinstance(first, Exception)
# Second line: valid message still comes through
second = await read_stream.receive()
assert isinstance(second, SessionMessage)
assert second.message == valid
class _GatedStdin(io.RawIOBase):
"""Raw stdin double: serves its frames, then blocks until released before EOF.
A real stdio client keeps stdin open until it has read the responses it is
awaiting; an immediate EOF after the last frame races the dispatcher's
EOF-time cancellation of in-flight handlers (only inline-handled methods
would deterministically answer first). The blocked read sits in
`stdio_server`'s reader worker thread and unblocks on `release()`.
"""
name = "<gated-stdin>"
def __init__(self, payload: bytes) -> None:
self._pending = payload
self._released = threading.Event()
def readable(self) -> bool:
return True
def readinto(self, b: Buffer) -> int:
view = memoryview(b)
if self._pending:
n = min(len(view), len(self._pending))
view[:n] = self._pending[:n]
self._pending = self._pending[n:]
return n
# A missed release falls through to EOF after the bound; the caller's
# own response assertions then report what actually arrived.
self._released.wait(5)
return 0
def release(self) -> None:
self._released.set()
class _NotifyingStdout(io.RawIOBase):
"""Raw stdout double that counts newline-terminated lines and can be awaited on.
Survives wrapper close (`close()` is a no-op) so the test can read what was
written after `run()` has torn its TextIOWrapper down.
"""
name = "<notifying-stdout>"
def __init__(self) -> None:
self._chunks: list[bytes] = []
self._lines = 0
self._cond = threading.Condition()
def writable(self) -> bool:
return True
def write(self, b: Buffer) -> int:
data = bytes(b)
with self._cond:
self._chunks.append(data)
self._lines += data.count(b"\n")
self._cond.notify_all()
return len(data)
def wait_for_lines(self, n: int, timeout: float = 5) -> bool:
with self._cond:
return self._cond.wait_for(lambda: self._lines >= n, timeout)
def getvalue(self) -> bytes:
with self._cond:
return b"".join(self._chunks)
def close(self) -> None:
pass
def _serve_stdio_and_collect(
monkeypatch: pytest.MonkeyPatch, server: MCPServer, frames: list[JSONRPCRequest], responses: int
) -> list[JSONRPCMessage]:
"""Serve `frames` over process stdio and return the parsed response lines.
Runs the blocking `server.run("stdio")` in a daemon thread (it creates its
own event loop, so a sync test cannot arm `anyio.fail_after`) and signals
stdin EOF only after `responses` lines arrive on stdout - the way a real
client closes the pipe - so spawned in-flight handlers never race the
dispatcher's EOF cancellation. The join bound turns a run loop that never
returns on stdin EOF into a red test instead of a silent CI hang; an
exception escaping `run()` still fails the test via pytest's
unhandled-thread warning, escalated by `filterwarnings = ["error"]`.
"""
payload = "".join(f.model_dump_json(by_alias=True, exclude_none=True) + "\n" for f in frames).encode()
stdin = _GatedStdin(payload)
stdout = _NotifyingStdout()
monkeypatch.setattr(sys, "stdin", TextIOWrapper(stdin, encoding="utf-8"))
monkeypatch.setattr(sys, "stdout", TextIOWrapper(stdout, encoding="utf-8"))
def target() -> None:
server.run("stdio")
thread = threading.Thread(target=target, daemon=True)
thread.start()
arrived = stdout.wait_for_lines(responses)
stdin.release()
thread.join(5)
assert not thread.is_alive(), 'run("stdio") did not return after stdin EOF'
assert arrived, f"expected {responses} response line(s); stdout carried: {stdout.getvalue()!r}"
return [jsonrpc_message_adapter.validate_json(line) for line in stdout.getvalue().decode().splitlines()]
def test_mcpserver_run_stdio_serves_until_stdin_closes(monkeypatch: pytest.MonkeyPatch) -> None:
"""`MCPServer.run("stdio")` serves over process stdio and returns at stdin EOF.
Answers a request over the process's stdio and returns when stdin reaches EOF,
rather than serving forever.
"""
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
responses = _serve_stdio_and_collect(monkeypatch, MCPServer(name="RunStdioServer"), [ping], 1)
assert responses == [JSONRPCResponse(jsonrpc="2.0", id=1, result={})]
def test_mcpserver_run_stdio_runs_lifespan_cleanup_after_stdin_closes(monkeypatch: pytest.MonkeyPatch) -> None:
"""Code after `yield` in a lifespan runs when stdin EOF ends `run("stdio")`.
Regression lock for the issue #1027 shutdown chain: the run loop must end on
stdin EOF and unwind the lifespan rather than be killed before returning.
"""
events: list[str] = []
@asynccontextmanager
async def lifespan(server: MCPServer) -> AsyncIterator[None]:
events.append("setup")
try:
yield
finally:
events.append("cleanup")
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
server = MCPServer(name="LifespanStdioServer", lifespan=lifespan)
responses = _serve_stdio_and_collect(monkeypatch, server, [ping], 1)
assert events == ["setup", "cleanup"]
assert responses == [JSONRPCResponse(jsonrpc="2.0", id=1, result={})]
def test_mcpserver_run_stdio_serves_a_modern_connection(monkeypatch: pytest.MonkeyPatch) -> None:
"""`MCPServer.run("stdio")` serves the modern era over process stdio.
A `server/discover` probe gets a DiscoverResult (no initialize handshake)
and a subsequent envelope-bearing request is served at the discovered
version - the wire exchange `Client(mode='auto')` drives against a stdio
server.
"""
envelope = {
PROTOCOL_VERSION_META_KEY: "2026-07-28",
CLIENT_INFO_META_KEY: {"name": "probe", "version": "1.0"},
CLIENT_CAPABILITIES_META_KEY: {},
}
discover = JSONRPCRequest(jsonrpc="2.0", id=1, method="server/discover", params={"_meta": envelope})
tools = JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={"_meta": envelope})
responses = _serve_stdio_and_collect(monkeypatch, MCPServer(name="ModernStdioServer"), [discover, tools], 2)
assert isinstance(responses[0], JSONRPCResponse) and responses[0].id == 1
assert "2026-07-28" in responses[0].result["supportedVersions"]
assert responses[0].result["serverInfo"]["name"] == "ModernStdioServer"
assert isinstance(responses[1], JSONRPCResponse) and responses[1].id == 2
# `resultType` is the modern-only wire field: its presence proves the
# request was served at the discovered version, not the handshake era.
assert responses[1].result["tools"] == []
assert responses[1].result["resultType"] == "complete"
@@ -0,0 +1,601 @@
"""Tests for StreamableHTTPSessionManager."""
import json
import logging
from typing import Any
from unittest.mock import AsyncMock, patch
import anyio
import httpx
import pytest
from mcp_types import INVALID_REQUEST, ListToolsResult, PaginatedRequestParams
from starlette.types import Message, Scope
from mcp import Client
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server, ServerRequestContext, streamable_http_manager
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
from mcp.server.auth.provider import AccessToken
from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, StreamableHTTPServerTransport
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
@pytest.mark.anyio
async def test_run_can_only_be_called_once():
"""Test that run() can only be called once per instance."""
app = Server("test-server")
manager = StreamableHTTPSessionManager(app=app)
# First call should succeed
async with manager.run():
pass
# Second call should raise RuntimeError
with pytest.raises(RuntimeError) as excinfo:
async with manager.run():
pass # pragma: no cover
assert "StreamableHTTPSessionManager .run() can only be called once per instance" in str(excinfo.value)
@pytest.mark.anyio
async def test_run_prevents_concurrent_calls():
"""Test that concurrent calls to run() are prevented."""
app = Server("test-server")
manager = StreamableHTTPSessionManager(app=app)
errors: list[Exception] = []
async def try_run():
try:
async with manager.run():
# Simulate some work
await anyio.sleep(0.1)
except RuntimeError as e:
errors.append(e)
# Try to run concurrently
async with anyio.create_task_group() as tg:
tg.start_soon(try_run)
tg.start_soon(try_run)
# One should succeed, one should fail
assert len(errors) == 1
assert "StreamableHTTPSessionManager .run() can only be called once per instance" in str(errors[0])
@pytest.mark.anyio
async def test_handle_request_without_run_raises_error():
"""Test that handle_request raises error if run() hasn't been called."""
app = Server("test-server")
manager = StreamableHTTPSessionManager(app=app)
# Mock ASGI parameters
scope = {"type": "http", "method": "POST", "path": "/test"}
async def receive(): # pragma: no cover
return {"type": "http.request", "body": b""}
async def send(message: Message): # pragma: no cover
pass
# Should raise error because run() hasn't been called
with pytest.raises(RuntimeError) as excinfo:
await manager.handle_request(scope, receive, send)
assert "Task group is not initialized. Make sure to use run()." in str(excinfo.value)
class TestException(Exception):
__test__ = False # Prevent pytest from collecting this as a test class
pass
@pytest.fixture
async def running_manager():
app = Server("test-cleanup-server")
# It's important that the app instance used by the manager is the one we can patch
manager = StreamableHTTPSessionManager(app=app)
async with manager.run():
# Patch app.run here if it's simpler, or patch it within the test
yield manager, app
@pytest.mark.anyio
async def test_stateful_session_cleanup_on_graceful_exit(running_manager: tuple[StreamableHTTPSessionManager, Server]):
manager, _app = running_manager
# The manager's `run_server` task drives `serve_loop` directly (the manager
# owns lifespan); patch that seam so the loop returns immediately and we
# can observe the cleanup that follows.
mock_serve = AsyncMock(return_value=None)
sent_messages: list[Message] = []
async def mock_send(message: Message):
sent_messages.append(message)
scope = {
"type": "http",
"method": "POST",
"path": "/mcp",
"headers": [(b"content-type", b"application/json")],
}
async def mock_receive(): # pragma: no cover
return {"type": "http.request", "body": b"", "more_body": False}
# Trigger session creation
with patch("mcp.server.streamable_http_manager.serve_loop", mock_serve):
await manager.handle_request(scope, mock_receive, mock_send)
# Extract session ID from response headers
session_id = None
for msg in sent_messages: # pragma: no branch
if msg["type"] == "http.response.start": # pragma: no branch
for header_name, header_value in msg.get("headers", []): # pragma: no branch
if header_name.decode().lower() == MCP_SESSION_ID_HEADER.lower():
session_id = header_value.decode()
break
if session_id: # Break outer loop if session_id is found # pragma: no branch
break
assert session_id is not None, "Session ID not found in response headers"
mock_serve.assert_called_once()
# At this point, mock_serve has completed, and the finally block in
# StreamableHTTPSessionManager's run_server should have executed.
# To ensure the task spawned by handle_request finishes and cleanup occurs:
# Give other tasks a chance to run. This is important for the finally block.
await anyio.sleep(0.01)
assert session_id not in manager._server_instances, (
"Session ID should be removed from _server_instances after graceful exit"
)
assert not manager._server_instances, "No sessions should be tracked after the only session exits gracefully"
@pytest.mark.anyio
async def test_stateful_session_cleanup_on_exception(running_manager: tuple[StreamableHTTPSessionManager, Server]):
manager, _app = running_manager
mock_serve = AsyncMock(side_effect=TestException("Simulated crash"))
sent_messages: list[Message] = []
async def mock_send(message: Message):
sent_messages.append(message)
# If an exception occurs, the transport might try to send an error response
# For this test, we mostly care that the session is established enough
# to get an ID
if message["type"] == "http.response.start" and message["status"] >= 500: # pragma: no cover
pass # Expected if TestException propagates that far up the transport
scope = {
"type": "http",
"method": "POST",
"path": "/mcp",
"headers": [(b"content-type", b"application/json")],
}
async def mock_receive(): # pragma: no cover
return {"type": "http.request", "body": b"", "more_body": False}
# Trigger session creation
with patch("mcp.server.streamable_http_manager.serve_loop", mock_serve):
await manager.handle_request(scope, mock_receive, mock_send)
session_id = None
for msg in sent_messages: # pragma: no branch
if msg["type"] == "http.response.start": # pragma: no branch
for header_name, header_value in msg.get("headers", []): # pragma: no branch
if header_name.decode().lower() == MCP_SESSION_ID_HEADER.lower():
session_id = header_value.decode()
break
if session_id: # Break outer loop if session_id is found # pragma: no branch
break
assert session_id is not None, "Session ID not found in response headers"
mock_serve.assert_called_once()
# Give other tasks a chance to run to ensure the finally block executes
await anyio.sleep(0.01)
assert session_id not in manager._server_instances, (
"Session ID should be removed from _server_instances after an exception"
)
assert not manager._server_instances, "No sessions should be tracked after the only session crashes"
@pytest.mark.anyio
async def test_stateless_requests_memory_cleanup():
"""Test that stateless requests actually clean up resources using real transports."""
app = Server("test-stateless-real-cleanup")
manager = StreamableHTTPSessionManager(app=app, stateless=True)
# Track created transport instances
created_transports: list[StreamableHTTPServerTransport] = []
# Patch StreamableHTTPServerTransport constructor to track instances
original_constructor = StreamableHTTPServerTransport
def track_transport(*args: Any, **kwargs: Any) -> StreamableHTTPServerTransport:
transport = original_constructor(*args, **kwargs)
created_transports.append(transport)
return transport
with patch.object(streamable_http_manager, "StreamableHTTPServerTransport", side_effect=track_transport):
async with manager.run():
# Send a simple request
sent_messages: list[Message] = []
async def mock_send(message: Message):
sent_messages.append(message)
scope = {
"type": "http",
"method": "POST",
"path": "/mcp",
"headers": [
(b"content-type", b"application/json"),
(b"accept", b"application/json, text/event-stream"),
],
}
# Empty body to trigger early return
async def mock_receive():
return {
"type": "http.request",
"body": b"",
"more_body": False,
}
# Send a request
await manager.handle_request(scope, mock_receive, mock_send)
# Verify transport was created
assert len(created_transports) == 1, "Should have created one transport"
transport = created_transports[0]
# The key assertion - transport should be terminated
assert transport._terminated, "Transport should be terminated after stateless request"
# Verify internal state is cleaned up
assert len(transport._request_streams) == 0, "Transport should have no active request streams"
@pytest.mark.anyio
async def test_unknown_session_id_returns_404(caplog: pytest.LogCaptureFixture):
"""Test that requests with unknown session IDs return HTTP 404 per MCP spec."""
app = Server("test-unknown-session")
manager = StreamableHTTPSessionManager(app=app)
async with manager.run():
sent_messages: list[Message] = []
response_body = b""
async def mock_send(message: Message):
nonlocal response_body
sent_messages.append(message)
if message["type"] == "http.response.body":
response_body += message.get("body", b"")
# Request with a non-existent session ID
scope = {
"type": "http",
"method": "POST",
"path": "/mcp",
"headers": [
(b"content-type", b"application/json"),
(b"accept", b"application/json, text/event-stream"),
(b"mcp-session-id", b"non-existent-session-id"),
],
}
async def mock_receive():
return {"type": "http.request", "body": b"{}", "more_body": False} # pragma: no cover
with caplog.at_level(logging.INFO):
await manager.handle_request(scope, mock_receive, mock_send)
# Find the response start message
response_start = next(
(msg for msg in sent_messages if msg["type"] == "http.response.start"),
None,
)
assert response_start is not None, "Should have sent a response"
assert response_start["status"] == 404, "Should return HTTP 404 for unknown session ID"
# Verify JSON-RPC error format
error_data = json.loads(response_body)
assert error_data["jsonrpc"] == "2.0"
assert error_data["id"] is None
assert error_data["error"]["code"] == INVALID_REQUEST
assert error_data["error"]["message"] == "Session not found"
assert "Rejected request with unknown or expired session ID: non-existent-session-id" in caplog.text
@pytest.mark.anyio
async def test_e2e_streamable_http_server_cleanup():
host = "testserver"
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[])
app = Server("test-server", on_list_tools=handle_list_tools)
mcp_app = app.streamable_http_app(host=host)
async with (
mcp_app.router.lifespan_context(mcp_app),
httpx.ASGITransport(mcp_app) as transport,
httpx.AsyncClient(transport=transport) as http_client,
Client(streamable_http_client(f"http://{host}/mcp", http_client=http_client), mode="legacy") as client,
):
await client.list_tools()
class _IdleTimeoutObserver(logging.Handler):
"""Resolves `reaped` when the manager logs that a session's idle timeout fired."""
def __init__(self) -> None:
super().__init__()
self.reaped = anyio.Event()
def emit(self, record: logging.LogRecord) -> None:
if "idle timeout" in record.getMessage():
self.reaped.set()
@pytest.mark.anyio
async def test_idle_session_is_reaped(caplog: pytest.LogCaptureFixture, request: pytest.FixtureRequest):
"""After idle timeout fires, the session returns 404."""
app = Server("test-idle-reap")
manager = StreamableHTTPSessionManager(app=app, session_idle_timeout=0.05)
# The reap is observed through the manager's own "idle timeout" log record: the manager pops
# the session synchronously after emitting it, before its next await, so a waiter woken by
# the record always finds the session gone. caplog.set_level enables INFO so it is created.
observer = _IdleTimeoutObserver()
manager_logger = logging.getLogger(streamable_http_manager.__name__)
manager_logger.addHandler(observer)
request.addfinalizer(lambda: manager_logger.removeHandler(observer))
caplog.set_level(logging.INFO, logger=streamable_http_manager.__name__)
async with manager.run():
sent_messages: list[Message] = []
async def mock_send(message: Message):
sent_messages.append(message)
scope = {
"type": "http",
"method": "POST",
"path": "/mcp",
"headers": [(b"content-type", b"application/json")],
}
async def mock_receive(): # pragma: no cover
return {"type": "http.request", "body": b"", "more_body": False}
await manager.handle_request(scope, mock_receive, mock_send)
session_id = None
for msg in sent_messages: # pragma: no branch
if msg["type"] == "http.response.start": # pragma: no branch
for header_name, header_value in msg.get("headers", []): # pragma: no branch
if header_name.decode().lower() == MCP_SESSION_ID_HEADER.lower():
session_id = header_value.decode()
break
if session_id: # pragma: no branch
break
assert session_id is not None, "Session ID not found in response headers"
# Wait for the 50ms idle timeout to fire and the session to be unregistered. Re-requesting
# the session to poll for the 404 would push its idle deadline forward and keep it alive.
with anyio.fail_after(5):
await observer.reaped.wait()
# Verify via public API: old session ID now returns 404
response_messages: list[Message] = []
async def capture_send(message: Message):
response_messages.append(message)
scope_with_session = {
"type": "http",
"method": "POST",
"path": "/mcp",
"headers": [
(b"content-type", b"application/json"),
(b"mcp-session-id", session_id.encode()),
],
}
await manager.handle_request(scope_with_session, mock_receive, capture_send)
response_start = next(
(msg for msg in response_messages if msg["type"] == "http.response.start"),
None,
)
assert response_start is not None
assert response_start["status"] == 404
def test_session_idle_timeout_rejects_non_positive():
with pytest.raises(ValueError, match="positive number"):
StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=-1)
with pytest.raises(ValueError, match="positive number"):
StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=0)
def test_session_idle_timeout_rejects_stateless():
with pytest.raises(RuntimeError, match="not supported in stateless"):
StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=30, stateless=True)
def _user(client_id: str, subject: str | None = None, issuer: str | None = None) -> AuthenticatedUser:
"""Build the scope["user"] value that AuthenticationMiddleware would set for this principal."""
claims = {"iss": issuer} if issuer is not None else None
return AuthenticatedUser(AccessToken(token="token", client_id=client_id, scopes=[], subject=subject, claims=claims))
def _request_scope(
*, session_id: str | None = None, user: AuthenticatedUser | None = None, method: str = "POST"
) -> Scope:
"""Build an ASGI scope for a request to the MCP endpoint."""
headers = [
(b"content-type", b"application/json"),
(b"accept", b"application/json, text/event-stream"),
]
if session_id is not None:
headers.append((b"mcp-session-id", session_id.encode()))
scope: Scope = {
"type": "http",
"method": method,
"path": "/mcp",
"headers": headers,
}
if user is not None:
scope["user"] = user
return scope
async def _open_session(manager: StreamableHTTPSessionManager, user: AuthenticatedUser | None) -> str:
"""Create a new session as `user` and return its session ID."""
sent_messages: list[Message] = []
async def mock_send(message: Message) -> None:
sent_messages.append(message)
async def mock_receive() -> Message:
return {"type": "http.request", "body": b"", "more_body": False}
await manager.handle_request(_request_scope(user=user), mock_receive, mock_send)
response_start = next(msg for msg in sent_messages if msg["type"] == "http.response.start")
headers = dict(response_start.get("headers", []))
return headers[MCP_SESSION_ID_HEADER.encode()].decode()
async def _request_session(
manager: StreamableHTTPSessionManager, session_id: str, user: AuthenticatedUser | None, method: str = "POST"
) -> int:
"""Send a request for an existing session as `user` and return the response status."""
sent_messages: list[Message] = []
async def mock_send(message: Message) -> None:
sent_messages.append(message)
async def mock_receive() -> Message:
return {"type": "http.request", "body": b"", "more_body": False}
await manager.handle_request(
_request_scope(session_id=session_id, user=user, method=method), mock_receive, mock_send
)
response_start = next(msg for msg in sent_messages if msg["type"] == "http.response.start")
return response_start["status"]
@pytest.fixture
async def manager_with_live_session():
"""A running manager around a real `Server`. Sessions remain registered until
`manager.run()` exits because `Server.run` blocks waiting for an initialize message."""
manager = StreamableHTTPSessionManager(app=Server("test-session-credentials"))
async with manager.run():
yield manager
@pytest.mark.anyio
async def test_session_accepts_requests_from_the_credential_that_created_it(
manager_with_live_session: StreamableHTTPSessionManager,
) -> None:
"""Requests presenting the same credential as the one that created the session are served."""
manager = manager_with_live_session
session_id = await _open_session(manager, _user("client-a"))
status = await _request_session(manager, session_id, _user("client-a"))
# The request passes the manager's credential check and reaches the
# session's transport, instead of being answered with 404 by the manager.
assert status != 404
@pytest.mark.anyio
@pytest.mark.parametrize("method", ["POST", "GET", "DELETE"])
async def test_session_rejects_requests_from_a_different_credential(
manager_with_live_session: StreamableHTTPSessionManager, method: str
) -> None:
"""A session created by one credential cannot be used with another credential, whatever the method."""
manager = manager_with_live_session
session_id = await _open_session(manager, _user("client-a"))
assert await _request_session(manager, session_id, _user("client-b"), method) == 404
# The session is still registered and still serves its creator.
assert await _request_session(manager, session_id, _user("client-a")) != 404
@pytest.mark.anyio
async def test_session_rejects_requests_from_a_different_subject_of_the_same_client(
manager_with_live_session: StreamableHTTPSessionManager,
) -> None:
"""Two end-users that share an OAuth client cannot use each other's sessions."""
manager = manager_with_live_session
session_id = await _open_session(manager, _user("client-a", subject="alice"))
assert await _request_session(manager, session_id, _user("client-a", subject="bob")) == 404
assert await _request_session(manager, session_id, _user("client-a", subject=None)) == 404
assert await _request_session(manager, session_id, _user("client-a", subject="alice")) != 404
@pytest.mark.anyio
async def test_session_rejects_requests_with_the_same_subject_from_a_different_issuer(
manager_with_live_session: StreamableHTTPSessionManager,
) -> None:
"""A subject is unique only per issuer, so a colliding subject from a different issuer is not the same principal."""
manager = manager_with_live_session
creator = _user("client-a", subject="alice", issuer="https://issuer.one")
session_id = await _open_session(manager, creator)
other_issuer = _user("client-a", subject="alice", issuer="https://issuer.two")
assert await _request_session(manager, session_id, other_issuer) == 404
assert await _request_session(manager, session_id, _user("client-a", subject="alice")) == 404
assert await _request_session(manager, session_id, creator) != 404
@pytest.mark.anyio
async def test_session_rejects_unauthenticated_requests_for_an_authenticated_session(
manager_with_live_session: StreamableHTTPSessionManager,
) -> None:
"""A session created with a credential cannot be used without one."""
manager = manager_with_live_session
session_id = await _open_session(manager, _user("client-a"))
assert await _request_session(manager, session_id, None) == 404
@pytest.mark.anyio
async def test_session_rejects_authenticated_requests_for_an_anonymous_session(
manager_with_live_session: StreamableHTTPSessionManager,
) -> None:
"""A session created without a credential cannot be used with one."""
manager = manager_with_live_session
session_id = await _open_session(manager, None)
assert await _request_session(manager, session_id, _user("client-a")) == 404
@pytest.mark.anyio
async def test_anonymous_session_accepts_anonymous_requests(
manager_with_live_session: StreamableHTTPSessionManager,
) -> None:
"""Servers without authentication keep working: no credential on either side."""
manager = manager_with_live_session
session_id = await _open_session(manager, None)
assert await _request_session(manager, session_id, None) != 404
File diff suppressed because it is too large Load Diff
+116
View File
@@ -0,0 +1,116 @@
"""Regression coverage for the StreamableHTTP per-session response router."""
import anyio
import pytest
from mcp_types import JSONRPCMessage, JSONRPCResponse
from starlette.types import Message, Scope
from mcp.server.streamable_http import (
REQUEST_STREAM_BUFFER_SIZE,
EventCallback,
EventId,
EventMessage,
EventStore,
StreamableHTTPServerTransport,
StreamId,
)
from mcp.shared.message import SessionMessage
class _PrimingFailingStore(EventStore):
async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId:
raise RuntimeError("backend unavailable")
async def replay_events_after(self, last_event_id: EventId, send_callback: EventCallback) -> StreamId | None:
raise NotImplementedError
@pytest.mark.anyio
async def test_router_unconsumed_request_stream_does_not_block_siblings() -> None:
"""A response whose `sse_writer` is not yet receiving must not park the router (#1764).
Drives the routing layer directly (the production race does not reproduce
on loopback), so this pins the router semantics, not the call sites.
"""
transport = StreamableHTTPServerTransport(mcp_session_id="sid", is_json_response_enabled=False)
streams = transport._request_streams
async with transport.connect() as (_read_stream, write_stream):
# Model two concurrent POSTs at the point _handle_post_request has
# registered the per-request stream but A's sse_writer has not yet
# reached its first receive().
streams["A"] = anyio.create_memory_object_stream[EventMessage](REQUEST_STREAM_BUFFER_SIZE)
streams["B"] = anyio.create_memory_object_stream[EventMessage](REQUEST_STREAM_BUFFER_SIZE)
a_send, a_recv = streams["A"]
b_reader = streams["B"][1]
b_received = anyio.Event()
async def consume_b() -> None:
async with b_reader:
await b_reader.receive()
b_received.set()
async def server_writes() -> None:
await write_stream.send(SessionMessage(JSONRPCResponse(jsonrpc="2.0", id="A", result={})))
await write_stream.send(SessionMessage(JSONRPCResponse(jsonrpc="2.0", id="B", result={})))
async with anyio.create_task_group() as tg:
tg.start_soon(consume_b)
tg.start_soon(server_writes)
with anyio.fail_after(5):
await b_received.wait()
# A's response was buffered for its (late) consumer, not dropped.
assert a_send.statistics().current_buffer_used == 1
await a_recv.aclose()
await a_send.aclose()
@pytest.mark.anyio
async def test_priming_store_failure_leaves_no_per_request_state() -> None:
"""`EventStore.store_event` raising on the priming row must not leak per-request entries."""
transport = StreamableHTTPServerTransport(
mcp_session_id=None,
is_json_response_enabled=False,
event_store=_PrimingFailingStore(),
)
body = b'{"jsonrpc":"2.0","id":"req-1","method":"tools/list","params":{}}'
scope: Scope = {
"type": "http",
"method": "POST",
"path": "/",
"query_string": b"",
"headers": [
(b"accept", b"application/json, text/event-stream"),
(b"content-type", b"application/json"),
(b"mcp-protocol-version", b"2025-11-25"),
],
}
body_sent = False
async def receive() -> Message:
nonlocal body_sent
if not body_sent:
body_sent = True
return {"type": "http.request", "body": body, "more_body": False}
raise NotImplementedError
sent: list[Message] = []
async def asgi_send(message: Message) -> None:
sent.append(message)
async with transport.connect() as (read_stream, _write_stream):
async with anyio.create_task_group() as tg:
tg.start_soon(transport.handle_request, scope, receive, asgi_send)
with anyio.fail_after(5):
forwarded = await read_stream.receive()
assert isinstance(forwarded, Exception)
# handle_request has returned; connect()'s finally (which clears
# _request_streams unconditionally) has not yet run.
assert transport._request_streams == {}
assert transport._sse_stream_writers == {}
assert sent[0]["type"] == "http.response.start"
assert sent[0]["status"] == 500
body = b"".join(m.get("body", b"") for m in sent if m["type"] == "http.response.body")
assert b"backend unavailable" not in body
@@ -0,0 +1,130 @@
"""Tests for StreamableHTTP server DNS rebinding protection."""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import httpx
import pytest
from starlette.applications import Starlette
from starlette.routing import Mount
from mcp.server import Server
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.server.transport_security import TransportSecuritySettings
from tests.interaction.transports import StreamingASGITransport
SERVER_NAME = "test_streamable_http_security_server"
# The in-process app is mounted at this origin purely so URLs are well-formed and the default
# Host header is a localhost form; nothing listens here.
BASE_URL = "http://127.0.0.1:8000"
@asynccontextmanager
async def streamable_http_security_client(
security_settings: TransportSecuritySettings | None = None,
) -> AsyncIterator[httpx.AsyncClient]:
"""Yield an httpx client served in process by a StreamableHTTP app with the given settings."""
session_manager = StreamableHTTPSessionManager(app=Server(SERVER_NAME), security_settings=security_settings)
app = Starlette(routes=[Mount("/", app=session_manager.handle_request)])
async with session_manager.run():
async with httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as client:
yield client
def _base_headers() -> dict[str, str]:
"""Headers every well-formed request carries, so each test varies only the header under test."""
return {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}
def _initialize_body() -> dict[str, object]:
"""A minimal initialize POST body; these tests assert header validation, not the handshake."""
return {"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}}
@pytest.mark.anyio
async def test_streamable_http_security_default_settings() -> None:
"""With default security settings, a request with localhost headers is served."""
async with streamable_http_security_client() as client:
response = await client.post("/", json=_initialize_body(), headers=_base_headers())
assert response.status_code == 200
assert "mcp-session-id" in response.headers
@pytest.mark.anyio
async def test_streamable_http_security_invalid_host_header() -> None:
"""A Host header outside allowed_hosts is rejected with 421."""
security_settings = TransportSecuritySettings(enable_dns_rebinding_protection=True)
async with streamable_http_security_client(security_settings) as client:
response = await client.post("/", json=_initialize_body(), headers=_base_headers() | {"Host": "evil.com"})
assert response.status_code == 421
assert response.text == "Invalid Host header"
@pytest.mark.anyio
async def test_streamable_http_security_invalid_origin_header() -> None:
"""An Origin header outside allowed_origins is rejected with 403."""
security_settings = TransportSecuritySettings(enable_dns_rebinding_protection=True, allowed_hosts=["127.0.0.1:*"])
async with streamable_http_security_client(security_settings) as client:
response = await client.post(
"/", json=_initialize_body(), headers=_base_headers() | {"Origin": "http://evil.com"}
)
assert response.status_code == 403
assert response.text == "Invalid Origin header"
@pytest.mark.anyio
async def test_streamable_http_security_invalid_content_type() -> None:
"""A POST whose Content-Type is not application/json (or is missing) is rejected with 400."""
async with streamable_http_security_client() as client:
response = await client.post("/", headers=_base_headers() | {"Content-Type": "text/plain"}, content="test")
assert response.status_code == 400
assert response.text == "Invalid Content-Type header"
response = await client.post("/", headers={"Accept": "application/json, text/event-stream"}, content="test")
assert response.status_code == 400
assert response.text == "Invalid Content-Type header"
@pytest.mark.anyio
async def test_streamable_http_security_disabled() -> None:
"""With protection explicitly disabled, a disallowed Host is still served."""
settings = TransportSecuritySettings(enable_dns_rebinding_protection=False)
async with streamable_http_security_client(settings) as client:
response = await client.post("/", json=_initialize_body(), headers=_base_headers() | {"Host": "evil.com"})
assert response.status_code == 200
@pytest.mark.anyio
async def test_streamable_http_security_custom_allowed_hosts() -> None:
"""A custom entry in allowed_hosts is served."""
settings = TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=["localhost", "127.0.0.1", "custom.host"],
allowed_origins=["http://localhost", "http://127.0.0.1", "http://custom.host"],
)
async with streamable_http_security_client(settings) as client:
response = await client.post("/", json=_initialize_body(), headers=_base_headers() | {"Host": "custom.host"})
assert response.status_code == 200
@pytest.mark.anyio
async def test_streamable_http_security_get_request() -> None:
"""GET requests pass the same Host validation before any session handling."""
security_settings = TransportSecuritySettings(enable_dns_rebinding_protection=True, allowed_hosts=["127.0.0.1"])
async with streamable_http_security_client(security_settings) as client:
response = await client.get("/", headers={"Accept": "text/event-stream", "Host": "evil.com"})
assert response.status_code == 421
assert response.text == "Invalid Host header"
response = await client.get("/", headers={"Accept": "text/event-stream", "Host": "127.0.0.1"})
# An allowed host passes security and fails on session validation instead.
assert response.status_code == 400
body = response.json()
assert "Missing session ID" in body["error"]["message"]
+473
View File
@@ -0,0 +1,473 @@
"""Tests for `subscriptions/listen` serving (mcp.server.subscriptions)."""
from collections.abc import Callable
from typing import Any, cast
import anyio
import pytest
from mcp_types import (
INVALID_REQUEST,
PromptListChangedNotification,
RequestId,
ResourceListChangedNotification,
ResourceUpdatedNotification,
ServerNotification,
SubscriptionFilter,
SubscriptionsAcknowledgedNotification,
SubscriptionsListenRequestParams,
SubscriptionsListenResult,
ToolListChangedNotification,
)
from mcp.server.context import ServerRequestContext
from mcp.server.session import ServerSession
from mcp.server.subscriptions import (
SUBSCRIPTION_ID_META_KEY,
InMemorySubscriptionBus,
ListenHandler,
PromptsListChanged,
ResourcesListChanged,
ResourceUpdated,
ServerEvent,
ToolsListChanged,
)
from mcp.shared.exceptions import MCPError
class _RecordingSession:
"""Stands in for `ServerSession`: records sent notifications and wakes waiters."""
def __init__(self) -> None:
self.sent: list[tuple[ServerNotification, RequestId | None]] = []
self._arrival = anyio.Event()
async def send_notification(
self, notification: ServerNotification, related_request_id: RequestId | None = None
) -> None:
self.sent.append((notification, related_request_id))
self._arrival.set()
self._arrival = anyio.Event()
async def wait_for(self, count: int) -> None:
with anyio.fail_after(5):
while len(self.sent) < count:
await self._arrival.wait()
def _ctx(session: _RecordingSession, request_id: RequestId | None = 7) -> ServerRequestContext[Any, Any]:
return ServerRequestContext(
session=cast(ServerSession, session),
lifespan_context={},
protocol_version="2026-07-28",
method="subscriptions/listen",
request_id=request_id,
)
def _params(**fields: Any) -> SubscriptionsListenRequestParams:
return SubscriptionsListenRequestParams(notifications=SubscriptionFilter(**fields))
class _SpyBus(InMemorySubscriptionBus):
"""Counts unsubscribe calls so tests can assert stream cleanup."""
def __init__(self) -> None:
super().__init__()
self.unsubscribed = 0
def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]:
unsubscribe = super().subscribe(listener)
def counting_unsubscribe() -> None:
self.unsubscribed += 1
unsubscribe()
return counting_unsubscribe
@pytest.mark.anyio
async def test_in_memory_bus_fans_out_until_unsubscribed() -> None:
"""SDK-defined bus contract: fan-out to all listeners; unsubscribe is idempotent."""
bus = InMemorySubscriptionBus()
seen_a: list[ServerEvent] = []
seen_b: list[ServerEvent] = []
unsubscribe_a = bus.subscribe(seen_a.append)
bus.subscribe(seen_b.append)
await bus.publish(ToolsListChanged())
assert seen_a == [ToolsListChanged()]
assert seen_b == [ToolsListChanged()]
unsubscribe_a()
unsubscribe_a() # idempotent
await bus.publish(PromptsListChanged())
assert seen_a == [ToolsListChanged()]
assert seen_b == [ToolsListChanged(), PromptsListChanged()]
@pytest.mark.anyio
async def test_in_memory_bus_keeps_equal_callables_distinct() -> None:
"""SDK-defined: registering the same callable twice yields two registrations,
and each unsubscribe detaches exactly one (bound methods compare equal)."""
bus = InMemorySubscriptionBus()
seen: list[ServerEvent] = []
first = bus.subscribe(seen.append)
bus.subscribe(seen.append)
await bus.publish(ToolsListChanged())
assert len(seen) == 2
first()
await bus.publish(ToolsListChanged())
assert len(seen) == 3
@pytest.mark.anyio
async def test_ack_first_honored_subset_and_stamped_graceful_result() -> None:
"""Spec-mandated: the ack is the first frame, echoes the honored subset, and
every frame (graceful result included) carries the subscription-id tag."""
bus = _SpyBus()
handler = ListenHandler(bus)
session = _RecordingSession()
results: list[SubscriptionsListenResult] = []
async with anyio.create_task_group() as tg:
async def run() -> None:
results.append(
await handler(
_ctx(session),
_params(tools_list_changed=True, prompts_list_changed=False, resource_subscriptions=["r://a"]),
)
)
tg.start_soon(run)
await session.wait_for(1)
ack, related = session.sent[0]
assert isinstance(ack, SubscriptionsAcknowledgedNotification)
assert related == 7
# Honored subset: requested-false and absent kinds are omitted, not echoed.
assert ack.params.notifications == SubscriptionFilter(tools_list_changed=True, resource_subscriptions=["r://a"])
assert ack.params.meta == {SUBSCRIPTION_ID_META_KEY: 7}
await bus.publish(ToolsListChanged())
await session.wait_for(2)
event, related = session.sent[1]
assert isinstance(event, ToolListChangedNotification)
assert related == 7
assert event.params is not None and event.params.meta == {SUBSCRIPTION_ID_META_KEY: 7}
handler.close()
assert results[0].meta == {SUBSCRIPTION_ID_META_KEY: 7}
assert bus.unsubscribed == 1 # the stream unsubscribed on the way out
@pytest.mark.anyio
async def test_only_requested_event_kinds_are_delivered() -> None:
"""Spec-mandated: the server never sends a notification type (or resource URI)
the client did not request on this stream."""
bus = InMemorySubscriptionBus()
handler = ListenHandler(bus)
session = _RecordingSession()
async with anyio.create_task_group() as tg:
async def run() -> None:
await handler(
_ctx(session),
_params(prompts_list_changed=True, resources_list_changed=True, resource_subscriptions=["r://a"]),
)
tg.start_soon(run)
await session.wait_for(1)
await bus.publish(ToolsListChanged()) # not requested
await bus.publish(ResourceUpdated(uri="r://other")) # URI not subscribed
await bus.publish(PromptsListChanged())
await bus.publish(ResourcesListChanged())
await bus.publish(ResourceUpdated(uri="r://a"))
await session.wait_for(4)
handler.close()
delivered = [notification for notification, _ in session.sent[1:]]
assert isinstance(delivered[0], PromptListChangedNotification)
assert isinstance(delivered[1], ResourceListChangedNotification)
assert isinstance(delivered[2], ResourceUpdatedNotification)
assert delivered[2].params.uri == "r://a"
assert delivered[2].params.meta == {SUBSCRIPTION_ID_META_KEY: 7}
assert len(delivered) == 3
@pytest.mark.anyio
async def test_empty_filter_honors_nothing_and_delivers_nothing() -> None:
"""SDK-defined: falsy flags and an empty URI list are dropped from the ack
rather than echoed, and such a stream delivers nothing."""
bus = InMemorySubscriptionBus()
handler = ListenHandler(bus)
session = _RecordingSession()
async with anyio.create_task_group() as tg:
async def run() -> None:
await handler(_ctx(session), _params(tools_list_changed=False, resource_subscriptions=[]))
tg.start_soon(run)
await session.wait_for(1)
ack, _ = session.sent[0]
assert isinstance(ack, SubscriptionsAcknowledgedNotification)
assert ack.params.notifications == SubscriptionFilter()
for event in (ToolsListChanged(), PromptsListChanged(), ResourcesListChanged(), ResourceUpdated(uri="r://a")):
await bus.publish(event)
handler.close()
assert len(session.sent) == 1 # the ack only
@pytest.mark.anyio
async def test_publish_after_close_is_dropped() -> None:
"""SDK-defined: an event racing `close()` while the stream unwinds is dropped."""
bus = InMemorySubscriptionBus()
handler = ListenHandler(bus)
session = _RecordingSession()
async with anyio.create_task_group() as tg:
async def run() -> None:
await handler(_ctx(session), _params(tools_list_changed=True))
tg.start_soon(run)
await session.wait_for(1)
handler.close()
# The handler task has not resumed yet, so the listener is still
# subscribed but its stream is closed: the event is dropped.
await bus.publish(ToolsListChanged())
assert len(session.sent) == 1
@pytest.mark.anyio
async def test_event_published_during_ack_send_is_delivered_after_the_ack() -> None:
"""SDK-defined: the stream subscribes before sending the ack, so an event
published while the ack write is suspended is buffered and delivered after
it - never lost, and never ahead of the ack frame."""
bus = InMemorySubscriptionBus()
handler = ListenHandler(bus)
class _PublishDuringAck(_RecordingSession):
async def send_notification(
self, notification: ServerNotification, related_request_id: RequestId | None = None
) -> None:
if not self.sent:
# Publish while the handler is still inside the ack send.
await bus.publish(ToolsListChanged())
await super().send_notification(notification, related_request_id)
session = _PublishDuringAck()
async with anyio.create_task_group() as tg:
async def run() -> None:
await handler(_ctx(session), _params(tools_list_changed=True))
tg.start_soon(run)
await session.wait_for(2)
handler.close()
assert isinstance(session.sent[0][0], SubscriptionsAcknowledgedNotification)
assert isinstance(session.sent[1][0], ToolListChangedNotification)
@pytest.mark.anyio
async def test_listen_requires_a_request_id() -> None:
"""SDK-defined: a context without a request id cannot open a stream."""
handler = ListenHandler(InMemorySubscriptionBus())
with pytest.raises(MCPError) as exc_info:
await handler(_ctx(_RecordingSession(), request_id=None), _params())
assert exc_info.value.error.code == INVALID_REQUEST
def test_close_without_open_streams_is_a_no_op() -> None:
"""SDK-defined: `close()` with nothing open does nothing."""
ListenHandler(InMemorySubscriptionBus()).close()
@pytest.mark.anyio
async def test_raising_listener_is_isolated_from_others() -> None:
"""SDK-defined: one raising listener is logged and skipped; later listeners
and the publishing handler are unaffected."""
bus = InMemorySubscriptionBus()
def bad(event: ServerEvent) -> None:
raise RuntimeError("boom")
seen: list[ServerEvent] = []
bus.subscribe(bad)
bus.subscribe(seen.append)
await bus.publish(ToolsListChanged())
assert seen == [ToolsListChanged()]
@pytest.mark.anyio
async def test_raising_unsubscribe_does_not_skip_stream_cleanup() -> None:
"""SDK-defined: a custom bus whose unsubscribe callable raises is logged
and isolated - the stream still releases its subscription slot, closes its
buffers, and returns the graceful result."""
class _RaisingUnsubscribeBus(InMemorySubscriptionBus):
def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]:
super().subscribe(listener)
def unsubscribe() -> None:
raise RuntimeError("boom")
return unsubscribe
handler = ListenHandler(_RaisingUnsubscribeBus(), max_subscriptions=1)
session = _RecordingSession()
results: list[SubscriptionsListenResult] = []
async with anyio.create_task_group() as tg:
async def run() -> None:
results.append(await handler(_ctx(session), _params(tools_list_changed=True)))
tg.start_soon(run)
await session.wait_for(1)
handler.close()
assert results[0].meta == {SUBSCRIPTION_ID_META_KEY: 7} # the graceful result still returned
# The slot was released despite the raising unsubscribe: a second listen
# is accepted at the cap of one.
late_session = _RecordingSession()
late_results: list[SubscriptionsListenResult] = []
async with anyio.create_task_group() as tg:
async def run_late() -> None:
late_results.append(await handler(_ctx(late_session, request_id=8), _params(tools_list_changed=True)))
tg.start_soon(run_late)
await late_session.wait_for(1)
handler.close()
assert late_results[0].meta == {SUBSCRIPTION_ID_META_KEY: 8}
@pytest.mark.anyio
async def test_subscription_limit_rejects_further_streams_pre_ack() -> None:
"""SDK-defined cap (mirrors the TypeScript SDK): past `max_subscriptions`,
a listen request is rejected before any ack frame."""
handler = ListenHandler(InMemorySubscriptionBus(), max_subscriptions=1)
session = _RecordingSession()
async with anyio.create_task_group() as tg:
async def run() -> None:
await handler(_ctx(session), _params(tools_list_changed=True))
tg.start_soon(run)
await session.wait_for(1)
rejected_session = _RecordingSession()
with pytest.raises(MCPError) as exc_info:
await handler(_ctx(rejected_session, request_id=8), _params())
assert exc_info.value.error.message == "Subscription limit reached"
assert rejected_session.sent == []
handler.close()
class _GatedSession(_RecordingSession):
"""Lets the ack through, then wedges event sends until released - a client
that stopped reading the transport."""
def __init__(self) -> None:
super().__init__()
self.wedged = anyio.Event()
self.release = anyio.Event()
async def send_notification(
self, notification: ServerNotification, related_request_id: RequestId | None = None
) -> None:
if self.sent: # the ack is the first frame; only event sends wedge
self.wedged.set()
await self.release.wait()
await super().send_notification(notification, related_request_id)
@pytest.mark.anyio
async def test_backlog_overflow_ends_the_stream_and_frees_its_slot() -> None:
"""SDK-defined: a stream whose client stopped reading is ended at
`max_buffered_events` rather than buffering forever. The subscription slot
frees at overflow time - the stream's own cleanup may be wedged in a
transport write nothing can wake - and the backlog still drains into the
stamped graceful result once that write completes."""
bus = InMemorySubscriptionBus()
handler = ListenHandler(bus, max_subscriptions=1, max_buffered_events=1)
session = _GatedSession()
results: list[SubscriptionsListenResult] = []
late_session = _RecordingSession()
late_results: list[SubscriptionsListenResult] = []
async with anyio.create_task_group() as tg:
async def run() -> None:
results.append(await handler(_ctx(session), _params(tools_list_changed=True)))
tg.start_soon(run)
await session.wait_for(1)
await bus.publish(ToolsListChanged()) # consumed, then wedged mid-send
with anyio.fail_after(5):
await session.wedged.wait()
await bus.publish(ToolsListChanged()) # fills the one-slot buffer
await bus.publish(ToolsListChanged()) # overflows: the stream is ended
async def run_late() -> None:
late_results.append(await handler(_ctx(late_session, request_id=8), _params(tools_list_changed=True)))
# The ended stream's slot is free immediately - a new listen does not
# wait for the wedged write to die with its connection.
tg.start_soon(run_late)
await late_session.wait_for(1)
session.release.set()
handler.close()
delivered = [notification for notification, _ in session.sent[1:]]
assert len(delivered) == 2 # the wedged event and the buffered one still drained
assert results[0].meta == {SUBSCRIPTION_ID_META_KEY: 7}
assert late_results[0].meta == {SUBSCRIPTION_ID_META_KEY: 8}
@pytest.mark.anyio
async def test_same_task_publish_burst_does_not_overflow_a_healthy_stream() -> None:
"""SDK-defined: `publish` ends with a checkpoint, so a burst of events from
one task (no yields of its own) lets a reading stream drain between
publishes instead of deterministically overflowing the buffer."""
bus = InMemorySubscriptionBus()
handler = ListenHandler(bus, max_buffered_events=99)
session = _RecordingSession()
async with anyio.create_task_group() as tg:
async def run() -> None:
await handler(_ctx(session), _params(tools_list_changed=True))
tg.start_soon(run)
await session.wait_for(1)
for _ in range(100):
await bus.publish(ToolsListChanged())
await session.wait_for(101)
handler.close()
assert len(session.sent) == 101 # the ack plus every event in the burst
+88
View File
@@ -0,0 +1,88 @@
"""Tests for the transport-security request validation middleware."""
import pytest
from starlette.requests import Request
from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings
def _request(host: str | None, origin: str | None, content_type: str | None = "application/json") -> Request:
headers: list[tuple[bytes, bytes]] = []
if content_type is not None:
headers.append((b"content-type", content_type.encode()))
if host is not None:
headers.append((b"host", host.encode()))
if origin is not None:
headers.append((b"origin", origin.encode()))
return Request({"type": "http", "method": "GET", "headers": headers})
SETTINGS = TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=["good.example", "wild.example:*"],
allowed_origins=["http://good.example", "http://wild.example:*"],
)
@pytest.mark.anyio
@pytest.mark.parametrize(
("host", "origin", "expected"),
[
pytest.param(None, None, 421, id="missing-host"),
pytest.param("evil.example", None, 421, id="host-no-match"),
pytest.param("evil.example:9000", None, 421, id="host-wildcard-base-mismatch"),
pytest.param("good.example", None, None, id="host-exact-no-origin"),
pytest.param("wild.example:9000", None, None, id="host-wildcard-match"),
pytest.param("good.example", "http://evil.example", 403, id="origin-no-match"),
pytest.param("good.example", "http://evil.example:9000", 403, id="origin-wildcard-base-mismatch"),
pytest.param("good.example", "http://good.example", None, id="origin-exact"),
pytest.param("good.example", "http://wild.example:9000", None, id="origin-wildcard-match"),
],
)
async def test_validate_request_checks_host_then_origin(
host: str | None, origin: str | None, expected: int | None
) -> None:
"""Host is checked first, then Origin; exact and wildcard-port allowlist entries are honoured."""
middleware = TransportSecurityMiddleware(SETTINGS)
response = await middleware.validate_request(_request(host, origin))
assert (None if response is None else response.status_code) == expected
@pytest.mark.anyio
async def test_validate_request_skips_host_and_origin_when_protection_is_disabled() -> None:
"""With DNS-rebinding protection off, any Host/Origin is accepted."""
middleware = TransportSecurityMiddleware(TransportSecuritySettings(enable_dns_rebinding_protection=False))
assert await middleware.validate_request(_request("evil.example", "http://evil.example")) is None
@pytest.mark.anyio
async def test_validate_request_defaults_to_protection_disabled() -> None:
"""Constructing the middleware without settings leaves DNS-rebinding protection off."""
middleware = TransportSecurityMiddleware()
assert await middleware.validate_request(_request("evil.example", "http://evil.example")) is None
@pytest.mark.anyio
@pytest.mark.parametrize(
("content_type", "expected"),
[
pytest.param("application/json", None, id="json"),
pytest.param("application/json; charset=utf-8", None, id="json-with-charset"),
pytest.param("APPLICATION/JSON", None, id="case-insensitive"),
pytest.param("text/plain", 400, id="wrong-type"),
pytest.param(None, 400, id="missing"),
],
)
async def test_validate_request_checks_content_type_on_post(content_type: str | None, expected: int | None) -> None:
"""POST requests must carry an application/json Content-Type, regardless of DNS-rebinding settings."""
middleware = TransportSecurityMiddleware()
response = await middleware.validate_request(_request("any", None, content_type=content_type), is_post=True)
assert (None if response is None else response.status_code) == expected
@pytest.mark.anyio
async def test_validate_request_ignores_content_type_on_get() -> None:
"""Content-Type is only enforced for POST requests."""
middleware = TransportSecurityMiddleware(SETTINGS)
response = await middleware.validate_request(_request("good.example", None, content_type="text/plain"))
assert response is None
+161
View File
@@ -0,0 +1,161 @@
"""Tests for server validation functions."""
import pytest
from mcp_types import (
ClientCapabilities,
SamplingCapability,
SamplingMessage,
SamplingToolsCapability,
TextContent,
Tool,
ToolChoice,
ToolResultContent,
ToolUseContent,
)
from mcp.server.validation import (
check_sampling_tools_capability,
validate_sampling_tools,
validate_tool_use_result_messages,
)
from mcp.shared.exceptions import MCPError
# Tests for check_sampling_tools_capability function
def test_check_sampling_tools_capability_returns_false_when_caps_none() -> None:
"""Returns False when client_caps is None."""
assert check_sampling_tools_capability(None) is False
def test_check_sampling_tools_capability_returns_false_when_sampling_none() -> None:
"""Returns False when client_caps.sampling is None."""
caps = ClientCapabilities()
assert check_sampling_tools_capability(caps) is False
def test_check_sampling_tools_capability_returns_false_when_tools_none() -> None:
"""Returns False when client_caps.sampling.tools is None."""
caps = ClientCapabilities(sampling=SamplingCapability())
assert check_sampling_tools_capability(caps) is False
def test_check_sampling_tools_capability_returns_true_when_tools_present() -> None:
"""Returns True when sampling.tools is present."""
caps = ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability()))
assert check_sampling_tools_capability(caps) is True
# Tests for validate_sampling_tools function
def test_validate_sampling_tools_no_error_when_tools_none() -> None:
"""No error when tools and tool_choice are None."""
validate_sampling_tools(None, None, None) # Should not raise
def test_validate_sampling_tools_raises_when_tools_provided_but_no_capability() -> None:
"""Raises MCPError when tools provided but client doesn't support."""
tool = Tool(name="test", input_schema={"type": "object"})
with pytest.raises(MCPError) as exc_info:
validate_sampling_tools(None, [tool], None)
assert "sampling tools capability" in str(exc_info.value)
def test_validate_sampling_tools_raises_when_tool_choice_provided_but_no_capability() -> None:
"""Raises MCPError when tool_choice provided but client doesn't support."""
with pytest.raises(MCPError) as exc_info:
validate_sampling_tools(None, None, ToolChoice(mode="auto"))
assert "sampling tools capability" in str(exc_info.value)
def test_validate_sampling_tools_no_error_when_capability_present() -> None:
"""No error when client has sampling.tools capability."""
caps = ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability()))
tool = Tool(name="test", input_schema={"type": "object"})
validate_sampling_tools(caps, [tool], ToolChoice(mode="auto")) # Should not raise
# Tests for validate_tool_use_result_messages function
def test_validate_tool_use_result_messages_no_error_for_empty_messages() -> None:
"""No error when messages list is empty."""
validate_tool_use_result_messages([]) # Should not raise
def test_validate_tool_use_result_messages_no_error_for_simple_text_messages() -> None:
"""No error for simple text messages."""
messages = [
SamplingMessage(role="user", content=TextContent(type="text", text="Hello")),
SamplingMessage(role="assistant", content=TextContent(type="text", text="Hi")),
]
validate_tool_use_result_messages(messages) # Should not raise
def test_validate_tool_use_result_messages_raises_when_tool_result_mixed_with_other_content() -> None:
"""Raises when tool_result is mixed with other content types."""
messages = [
SamplingMessage(
role="user",
content=[
ToolResultContent(type="tool_result", tool_use_id="123"),
TextContent(type="text", text="also this"),
],
),
]
with pytest.raises(ValueError, match="only tool_result content"):
validate_tool_use_result_messages(messages)
def test_validate_tool_use_result_messages_raises_when_tool_result_without_previous_tool_use() -> None:
"""Raises when tool_result appears without preceding tool_use."""
messages = [
SamplingMessage(
role="user",
content=ToolResultContent(type="tool_result", tool_use_id="123"),
),
]
with pytest.raises(ValueError, match="previous message containing tool_use"):
validate_tool_use_result_messages(messages)
def test_validate_tool_use_result_messages_raises_when_previous_message_has_no_tool_use() -> None:
"""Raises when tool_result follows a message that has content but no tool_use."""
messages = [
SamplingMessage(role="assistant", content=TextContent(type="text", text="just text")),
SamplingMessage(role="user", content=ToolResultContent(type="tool_result", tool_use_id="tool-1")),
]
with pytest.raises(ValueError, match="do not match any tool_use in the previous message"):
validate_tool_use_result_messages(messages)
def test_validate_tool_use_result_messages_raises_when_tool_result_ids_dont_match_tool_use() -> None:
"""Raises when tool_result IDs don't match tool_use IDs."""
messages = [
SamplingMessage(
role="assistant",
content=ToolUseContent(type="tool_use", id="tool-1", name="test", input={}),
),
SamplingMessage(
role="user",
content=ToolResultContent(type="tool_result", tool_use_id="tool-2"),
),
]
with pytest.raises(ValueError, match="do not match"):
validate_tool_use_result_messages(messages)
def test_validate_tool_use_result_messages_no_error_when_tool_result_matches_tool_use() -> None:
"""No error when tool_result IDs match tool_use IDs."""
messages = [
SamplingMessage(
role="assistant",
content=ToolUseContent(type="tool_use", id="tool-1", name="test", input={}),
),
SamplingMessage(
role="user",
content=ToolResultContent(type="tool_result", tool_use_id="tool-1"),
),
]
validate_tool_use_result_messages(messages) # Should not raise