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
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:
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the MCP server auth components."""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user