Add pyright strict mode on the whole project (#1254)
This commit is contained in:
parent
ef4e167c1a
commit
c7671e470c
21 changed files with 652 additions and 674 deletions
|
|
@ -11,16 +11,8 @@ 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,
|
||||
)
|
||||
from mcp.server.auth.provider import (
|
||||
AccessToken,
|
||||
OAuthAuthorizationServerProvider,
|
||||
ProviderTokenVerifier,
|
||||
)
|
||||
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, BearerAuthBackend, RequireAuthMiddleware
|
||||
from mcp.server.auth.provider import AccessToken, OAuthAuthorizationServerProvider, ProviderTokenVerifier
|
||||
|
||||
|
||||
class MockOAuthProvider:
|
||||
|
|
@ -31,7 +23,7 @@ class MockOAuthProvider:
|
|||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.tokens = {} # token -> AccessToken
|
||||
self.tokens: dict[str, AccessToken] = {} # token -> AccessToken
|
||||
|
||||
def add_token(self, token: str, access_token: AccessToken) -> None:
|
||||
"""Add a token to the provider."""
|
||||
|
|
@ -287,7 +279,7 @@ class TestRequireAuthMiddleware:
|
|||
async def receive() -> Message:
|
||||
return {"type": "http.request"}
|
||||
|
||||
sent_messages = []
|
||||
sent_messages: list[Message] = []
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
sent_messages.append(message)
|
||||
|
|
@ -311,7 +303,7 @@ class TestRequireAuthMiddleware:
|
|||
async def receive() -> Message:
|
||||
return {"type": "http.request"}
|
||||
|
||||
sent_messages = []
|
||||
sent_messages: list[Message] = []
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
sent_messages.append(message)
|
||||
|
|
@ -340,7 +332,7 @@ class TestRequireAuthMiddleware:
|
|||
async def receive() -> Message:
|
||||
return {"type": "http.request"}
|
||||
|
||||
sent_messages = []
|
||||
sent_messages: list[Message] = []
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
sent_messages.append(message)
|
||||
|
|
@ -368,7 +360,7 @@ class TestRequireAuthMiddleware:
|
|||
async def receive() -> Message:
|
||||
return {"type": "http.request"}
|
||||
|
||||
sent_messages = []
|
||||
sent_messages: list[Message] = []
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
sent_messages.append(message)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ Tests for OAuth error handling in the auth handlers.
|
|||
"""
|
||||
|
||||
import unittest.mock
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
|
|
@ -11,15 +12,14 @@ 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.provider import AuthorizeError, RegistrationError, TokenError
|
||||
from mcp.server.auth.routes import create_auth_routes
|
||||
from tests.server.fastmcp.auth.test_auth_integration import (
|
||||
MockOAuthProvider,
|
||||
)
|
||||
|
||||
# TODO(Marcelo): This TYPE_CHECKING shouldn't be here, but pytest doesn't seem to get the module correctly.
|
||||
if TYPE_CHECKING:
|
||||
from ...server.fastmcp.auth.test_auth_integration import MockOAuthProvider
|
||||
else:
|
||||
from tests.server.fastmcp.auth.test_auth_integration import MockOAuthProvider
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -29,7 +29,7 @@ def oauth_provider():
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def app(oauth_provider):
|
||||
def app(oauth_provider: MockOAuthProvider):
|
||||
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
|
||||
|
||||
# Enable client registration
|
||||
|
|
@ -49,7 +49,7 @@ def app(oauth_provider):
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
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")
|
||||
|
|
@ -74,7 +74,7 @@ def pkce_challenge():
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
async def registered_client(client):
|
||||
async def registered_client(client: httpx.AsyncClient) -> dict[str, Any]:
|
||||
"""Create and register a test client."""
|
||||
# Default client metadata
|
||||
client_metadata = {
|
||||
|
|
@ -94,7 +94,7 @@ async def registered_client(client):
|
|||
|
||||
class TestRegistrationErrorHandling:
|
||||
@pytest.mark.anyio
|
||||
async def test_registration_error_handling(self, client, oauth_provider):
|
||||
async def test_registration_error_handling(self, client: httpx.AsyncClient, oauth_provider: MockOAuthProvider):
|
||||
# Mock the register_client method to raise a registration error
|
||||
with unittest.mock.patch.object(
|
||||
oauth_provider,
|
||||
|
|
@ -128,7 +128,13 @@ class TestRegistrationErrorHandling:
|
|||
|
||||
class TestAuthorizeErrorHandling:
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_error_handling(self, client, oauth_provider, registered_client, pkce_challenge):
|
||||
async def test_authorize_error_handling(
|
||||
self,
|
||||
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,
|
||||
|
|
@ -165,7 +171,13 @@ class TestAuthorizeErrorHandling:
|
|||
|
||||
class TestTokenErrorHandling:
|
||||
@pytest.mark.anyio
|
||||
async def test_token_error_handling_auth_code(self, client, oauth_provider, registered_client, pkce_challenge):
|
||||
async def test_token_error_handling_auth_code(
|
||||
self,
|
||||
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"]
|
||||
|
|
@ -218,7 +230,13 @@ class TestTokenErrorHandling:
|
|||
assert data["error_description"] == "The authorization code is invalid"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_token_error_handling_refresh_token(self, client, oauth_provider, registered_client, pkce_challenge):
|
||||
async def test_token_error_handling_refresh_token(
|
||||
self,
|
||||
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"]
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import hashlib
|
|||
import secrets
|
||||
import time
|
||||
import unittest.mock
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
|
|
@ -22,24 +23,17 @@ from mcp.server.auth.provider import (
|
|||
RefreshToken,
|
||||
construct_redirect_uri,
|
||||
)
|
||||
from mcp.server.auth.routes import (
|
||||
ClientRegistrationOptions,
|
||||
RevocationOptions,
|
||||
create_auth_routes,
|
||||
)
|
||||
from mcp.shared.auth import (
|
||||
OAuthClientInformationFull,
|
||||
OAuthToken,
|
||||
)
|
||||
from mcp.server.auth.routes import ClientRegistrationOptions, RevocationOptions, create_auth_routes
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
||||
|
||||
|
||||
# Mock OAuth provider for testing
|
||||
class MockOAuthProvider(OAuthAuthorizationServerProvider):
|
||||
class MockOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]):
|
||||
def __init__(self):
|
||||
self.clients = {}
|
||||
self.auth_codes = {} # code -> {client_id, code_challenge, redirect_uri}
|
||||
self.tokens = {} # token -> {client_id, scopes, expires_at}
|
||||
self.refresh_tokens = {} # refresh_token -> access_token
|
||||
self.clients: dict[str, OAuthClientInformationFull] = {}
|
||||
self.auth_codes: dict[str, AuthorizationCode] = {} # code -> {client_id, code_challenge, redirect_uri}
|
||||
self.tokens: dict[str, AccessToken] = {} # token -> {client_id, scopes, expires_at}
|
||||
self.refresh_tokens: dict[str, str] = {} # refresh_token -> access_token
|
||||
|
||||
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
||||
return self.clients.get(client_id)
|
||||
|
|
@ -196,7 +190,7 @@ def mock_oauth_provider():
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_app(mock_oauth_provider):
|
||||
def auth_app(mock_oauth_provider: MockOAuthProvider):
|
||||
# Create auth router
|
||||
auth_routes = create_auth_routes(
|
||||
mock_oauth_provider,
|
||||
|
|
@ -217,13 +211,15 @@ def auth_app(mock_oauth_provider):
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_client(auth_app):
|
||||
async def test_client(auth_app: Starlette):
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app), base_url="https://mcptest.com") as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def registered_client(test_client: httpx.AsyncClient, request):
|
||||
async def registered_client(
|
||||
test_client: httpx.AsyncClient, request: pytest.FixtureRequest
|
||||
) -> OAuthClientInformationFull:
|
||||
"""Create and register a test client.
|
||||
|
||||
Parameters can be customized via indirect parameterization:
|
||||
|
|
@ -259,7 +255,12 @@ def pkce_challenge():
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_code(test_client, registered_client, pkce_challenge, request):
|
||||
async def auth_code(
|
||||
test_client: httpx.AsyncClient,
|
||||
registered_client: dict[str, Any],
|
||||
pkce_challenge: dict[str, str],
|
||||
request: pytest.FixtureRequest,
|
||||
):
|
||||
"""Get an authorization code.
|
||||
|
||||
Parameters can be customized via indirect parameterization:
|
||||
|
|
@ -300,7 +301,13 @@ async def auth_code(test_client, registered_client, pkce_challenge, request):
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
async def tokens(test_client, registered_client, auth_code, pkce_challenge, request):
|
||||
async def tokens(
|
||||
test_client: httpx.AsyncClient,
|
||||
registered_client: dict[str, Any],
|
||||
auth_code: dict[str, str],
|
||||
pkce_challenge: dict[str, str],
|
||||
request: pytest.FixtureRequest,
|
||||
):
|
||||
"""Exchange authorization code for tokens.
|
||||
|
||||
Parameters can be customized via indirect parameterization:
|
||||
|
|
@ -373,7 +380,12 @@ class TestAuthEndpoints:
|
|||
assert "error_description" in error_response # Contains validation error messages
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_token_invalid_auth_code(self, test_client, registered_client, pkce_challenge):
|
||||
async def test_token_invalid_auth_code(
|
||||
self,
|
||||
test_client: httpx.AsyncClient,
|
||||
registered_client: dict[str, Any],
|
||||
pkce_challenge: dict[str, str],
|
||||
):
|
||||
"""Test token endpoint error - authorization code does not exist."""
|
||||
# Try to use a non-existent authorization code
|
||||
response = await test_client.post(
|
||||
|
|
@ -398,11 +410,11 @@ class TestAuthEndpoints:
|
|||
@pytest.mark.anyio
|
||||
async def test_token_expired_auth_code(
|
||||
self,
|
||||
test_client,
|
||||
registered_client,
|
||||
auth_code,
|
||||
pkce_challenge,
|
||||
mock_oauth_provider,
|
||||
test_client: httpx.AsyncClient,
|
||||
registered_client: dict[str, Any],
|
||||
auth_code: dict[str, str],
|
||||
pkce_challenge: dict[str, str],
|
||||
mock_oauth_provider: MockOAuthProvider,
|
||||
):
|
||||
"""Test token endpoint error - authorization code has expired."""
|
||||
# Get the current time for our time mocking
|
||||
|
|
@ -451,7 +463,13 @@ class TestAuthEndpoints:
|
|||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_token_redirect_uri_mismatch(self, test_client, registered_client, auth_code, pkce_challenge):
|
||||
async def test_token_redirect_uri_mismatch(
|
||||
self,
|
||||
test_client: httpx.AsyncClient,
|
||||
registered_client: dict[str, Any],
|
||||
auth_code: dict[str, str],
|
||||
pkce_challenge: dict[str, str],
|
||||
):
|
||||
"""Test token endpoint error - redirect URI mismatch."""
|
||||
# Try to use the code with a different redirect URI
|
||||
response = await test_client.post(
|
||||
|
|
@ -472,7 +490,9 @@ class TestAuthEndpoints:
|
|||
assert "redirect_uri did not match" in error_response["error_description"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_token_code_verifier_mismatch(self, test_client, registered_client, auth_code):
|
||||
async def test_token_code_verifier_mismatch(
|
||||
self, test_client: httpx.AsyncClient, registered_client: dict[str, Any], auth_code: dict[str, str]
|
||||
):
|
||||
"""Test token endpoint error - PKCE code verifier mismatch."""
|
||||
# Try to use the code with an incorrect code verifier
|
||||
response = await test_client.post(
|
||||
|
|
@ -493,7 +513,7 @@ class TestAuthEndpoints:
|
|||
assert "incorrect code_verifier" in error_response["error_description"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_token_invalid_refresh_token(self, test_client, registered_client):
|
||||
async def test_token_invalid_refresh_token(self, test_client: httpx.AsyncClient, registered_client: dict[str, Any]):
|
||||
"""Test token endpoint error - refresh token does not exist."""
|
||||
# Try to use a non-existent refresh token
|
||||
response = await test_client.post(
|
||||
|
|
@ -513,11 +533,10 @@ class TestAuthEndpoints:
|
|||
@pytest.mark.anyio
|
||||
async def test_token_expired_refresh_token(
|
||||
self,
|
||||
test_client,
|
||||
registered_client,
|
||||
auth_code,
|
||||
pkce_challenge,
|
||||
mock_oauth_provider,
|
||||
test_client: httpx.AsyncClient,
|
||||
registered_client: dict[str, Any],
|
||||
auth_code: dict[str, str],
|
||||
pkce_challenge: dict[str, str],
|
||||
):
|
||||
"""Test token endpoint error - refresh token has expired."""
|
||||
# Step 1: First, let's create a token and refresh token at the current time
|
||||
|
|
@ -560,7 +579,13 @@ class TestAuthEndpoints:
|
|||
assert "refresh token has expired" in error_response["error_description"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_token_invalid_scope(self, test_client, registered_client, auth_code, pkce_challenge):
|
||||
async def test_token_invalid_scope(
|
||||
self,
|
||||
test_client: httpx.AsyncClient,
|
||||
registered_client: dict[str, Any],
|
||||
auth_code: dict[str, str],
|
||||
pkce_challenge: dict[str, str],
|
||||
):
|
||||
"""Test token endpoint error - invalid scope in refresh token request."""
|
||||
# Exchange authorization code for tokens
|
||||
token_response = await test_client.post(
|
||||
|
|
@ -664,8 +689,9 @@ class TestAuthEndpoints:
|
|||
@pytest.mark.anyio
|
||||
async def test_client_registration_empty_redirect_uris(self, test_client: httpx.AsyncClient):
|
||||
"""Test client registration with empty redirect_uris array."""
|
||||
redirect_uris: list[str] = []
|
||||
client_metadata = {
|
||||
"redirect_uris": [], # Empty array
|
||||
"redirect_uris": redirect_uris, # Empty array
|
||||
"client_name": "Test Client",
|
||||
}
|
||||
|
||||
|
|
@ -682,12 +708,7 @@ class TestAuthEndpoints:
|
|||
)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_form_post(
|
||||
self,
|
||||
test_client: httpx.AsyncClient,
|
||||
mock_oauth_provider: MockOAuthProvider,
|
||||
pkce_challenge,
|
||||
):
|
||||
async def test_authorize_form_post(self, test_client: httpx.AsyncClient, pkce_challenge: dict[str, str]):
|
||||
"""Test the authorization endpoint using POST with form-encoded data."""
|
||||
# Register a client
|
||||
client_metadata = {
|
||||
|
|
@ -730,7 +751,7 @@ class TestAuthEndpoints:
|
|||
self,
|
||||
test_client: httpx.AsyncClient,
|
||||
mock_oauth_provider: MockOAuthProvider,
|
||||
pkce_challenge,
|
||||
pkce_challenge: dict[str, str],
|
||||
):
|
||||
"""Test the full authorization flow."""
|
||||
# 1. Register a client
|
||||
|
|
@ -836,7 +857,7 @@ class TestAuthEndpoints:
|
|||
assert await mock_oauth_provider.load_access_token(new_token_response["access_token"]) is None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_revoke_invalid_token(self, test_client, registered_client):
|
||||
async def test_revoke_invalid_token(self, test_client: httpx.AsyncClient, registered_client: dict[str, Any]):
|
||||
"""Test revoking an invalid token."""
|
||||
response = await test_client.post(
|
||||
"/revoke",
|
||||
|
|
@ -850,7 +871,7 @@ class TestAuthEndpoints:
|
|||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_revoke_with_malformed_token(self, test_client, registered_client):
|
||||
async def test_revoke_with_malformed_token(self, test_client: httpx.AsyncClient, registered_client: dict[str, Any]):
|
||||
response = await test_client.post(
|
||||
"/revoke",
|
||||
data={
|
||||
|
|
@ -874,10 +895,7 @@ class TestAuthEndpoints:
|
|||
"scope": "read write profile admin", # 'admin' is not in valid_scopes
|
||||
}
|
||||
|
||||
response = await test_client.post(
|
||||
"/register",
|
||||
json=client_metadata,
|
||||
)
|
||||
response = await test_client.post("/register", json=client_metadata)
|
||||
assert response.status_code == 400
|
||||
error_data = response.json()
|
||||
assert "error" in error_data
|
||||
|
|
@ -895,10 +913,7 @@ class TestAuthEndpoints:
|
|||
# No scope specified
|
||||
}
|
||||
|
||||
response = await test_client.post(
|
||||
"/register",
|
||||
json=client_metadata,
|
||||
)
|
||||
response = await test_client.post("/register", json=client_metadata)
|
||||
assert response.status_code == 201
|
||||
client_info = response.json()
|
||||
|
||||
|
|
@ -920,10 +935,7 @@ class TestAuthEndpoints:
|
|||
"grant_types": ["authorization_code"],
|
||||
}
|
||||
|
||||
response = await test_client.post(
|
||||
"/register",
|
||||
json=client_metadata,
|
||||
)
|
||||
response = await test_client.post("/register", json=client_metadata)
|
||||
assert response.status_code == 400
|
||||
error_data = response.json()
|
||||
assert "error" in error_data
|
||||
|
|
@ -935,7 +947,7 @@ class TestAuthorizeEndpointErrors:
|
|||
"""Test error handling in the OAuth authorization endpoint."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_missing_client_id(self, test_client: httpx.AsyncClient, pkce_challenge):
|
||||
async def test_authorize_missing_client_id(self, test_client: httpx.AsyncClient, pkce_challenge: dict[str, str]):
|
||||
"""Test authorization endpoint with missing client_id.
|
||||
|
||||
According to the OAuth2.0 spec, if client_id is missing, the server should
|
||||
|
|
@ -959,7 +971,7 @@ class TestAuthorizeEndpointErrors:
|
|||
assert "client_id" in response.text.lower()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_invalid_client_id(self, test_client: httpx.AsyncClient, pkce_challenge):
|
||||
async def test_authorize_invalid_client_id(self, test_client: httpx.AsyncClient, pkce_challenge: dict[str, str]):
|
||||
"""Test authorization endpoint with invalid client_id.
|
||||
|
||||
According to the OAuth2.0 spec, if client_id is invalid, the server should
|
||||
|
|
@ -984,7 +996,7 @@ class TestAuthorizeEndpointErrors:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_missing_redirect_uri(
|
||||
self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
|
||||
self, test_client: httpx.AsyncClient, registered_client: dict[str, Any], pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test authorization endpoint with missing redirect_uri.
|
||||
|
||||
|
|
@ -1010,7 +1022,7 @@ class TestAuthorizeEndpointErrors:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_invalid_redirect_uri(
|
||||
self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
|
||||
self, test_client: httpx.AsyncClient, registered_client: dict[str, Any], pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test authorization endpoint with invalid redirect_uri.
|
||||
|
||||
|
|
@ -1050,7 +1062,7 @@ class TestAuthorizeEndpointErrors:
|
|||
indirect=True,
|
||||
)
|
||||
async def test_authorize_missing_redirect_uri_multiple_registered(
|
||||
self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
|
||||
self, test_client: httpx.AsyncClient, registered_client: dict[str, Any], pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test endpoint with missing redirect_uri with multiple registered URIs.
|
||||
|
||||
|
|
@ -1076,7 +1088,7 @@ class TestAuthorizeEndpointErrors:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_unsupported_response_type(
|
||||
self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
|
||||
self, test_client: httpx.AsyncClient, registered_client: dict[str, Any], pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test authorization endpoint with unsupported response_type.
|
||||
|
||||
|
|
@ -1110,7 +1122,7 @@ class TestAuthorizeEndpointErrors:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_missing_response_type(
|
||||
self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
|
||||
self, test_client: httpx.AsyncClient, registered_client: dict[str, Any], pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test authorization endpoint with missing response_type.
|
||||
|
||||
|
|
@ -1142,7 +1154,9 @@ class TestAuthorizeEndpointErrors:
|
|||
assert query_params["state"][0] == "test_state"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_missing_pkce_challenge(self, test_client: httpx.AsyncClient, registered_client):
|
||||
async def test_authorize_missing_pkce_challenge(
|
||||
self, test_client: httpx.AsyncClient, registered_client: dict[str, Any]
|
||||
):
|
||||
"""Test authorization endpoint with missing PKCE code_challenge.
|
||||
|
||||
Missing PKCE parameters should result in invalid_request error.
|
||||
|
|
@ -1171,7 +1185,9 @@ class TestAuthorizeEndpointErrors:
|
|||
assert query_params["state"][0] == "test_state"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_invalid_scope(self, test_client: httpx.AsyncClient, registered_client, pkce_challenge):
|
||||
async def test_authorize_invalid_scope(
|
||||
self, test_client: httpx.AsyncClient, registered_client: dict[str, Any], pkce_challenge: dict[str, str]
|
||||
):
|
||||
"""Test authorization endpoint with invalid scope.
|
||||
|
||||
Invalid scope should redirect with invalid_scope error.
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import FileUrl
|
||||
|
||||
from mcp.server.fastmcp.prompts.base import (
|
||||
AssistantMessage,
|
||||
Message,
|
||||
Prompt,
|
||||
TextContent,
|
||||
UserMessage,
|
||||
)
|
||||
from mcp.server.fastmcp.prompts.base import AssistantMessage, Message, Prompt, TextContent, UserMessage
|
||||
from mcp.types import EmbeddedResource, TextResourceContents
|
||||
|
||||
|
||||
|
|
@ -65,7 +61,7 @@ class TestRenderPrompt:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_fn_returns_multiple_messages(self):
|
||||
expected = [
|
||||
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."),
|
||||
|
|
@ -160,7 +156,7 @@ class TestRenderPrompt:
|
|||
async def test_fn_returns_dict_with_resource(self):
|
||||
"""Test returning a dict with resource content."""
|
||||
|
||||
async def fn() -> dict:
|
||||
async def fn() -> dict[str, Any]:
|
||||
return {
|
||||
"role": "user",
|
||||
"content": {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ class TestPromptManager:
|
|||
assert added == prompt
|
||||
assert manager.get_prompt("fn") == prompt
|
||||
|
||||
def test_add_duplicate_prompt(self, caplog):
|
||||
def test_add_duplicate_prompt(self, caplog: pytest.LogCaptureFixture):
|
||||
"""Test adding the same prompt twice."""
|
||||
|
||||
def fn() -> str:
|
||||
|
|
@ -30,7 +30,7 @@ class TestPromptManager:
|
|||
assert first == second
|
||||
assert "Prompt already exists" in caplog.text
|
||||
|
||||
def test_disable_warn_on_duplicate_prompts(self, caplog):
|
||||
def test_disable_warn_on_duplicate_prompts(self, caplog: pytest.LogCaptureFixture):
|
||||
"""Test disabling warning on duplicate prompts."""
|
||||
|
||||
def fn() -> str:
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ class TestFunctionResource:
|
|||
async def test_json_conversion(self):
|
||||
"""Test automatic JSON conversion of non-string results."""
|
||||
|
||||
def get_data() -> dict:
|
||||
def get_data() -> dict[str, str]:
|
||||
return {"key": "value"}
|
||||
|
||||
resource = FunctionResource(
|
||||
|
|
|
|||
|
|
@ -4,12 +4,7 @@ from tempfile import NamedTemporaryFile
|
|||
import pytest
|
||||
from pydantic import AnyUrl, FileUrl
|
||||
|
||||
from mcp.server.fastmcp.resources import (
|
||||
FileResource,
|
||||
FunctionResource,
|
||||
ResourceManager,
|
||||
ResourceTemplate,
|
||||
)
|
||||
from mcp.server.fastmcp.resources import FileResource, FunctionResource, ResourceManager, ResourceTemplate
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -57,7 +52,7 @@ class TestResourceManager:
|
|||
assert first == second
|
||||
assert manager.list_resources() == [resource]
|
||||
|
||||
def test_warn_on_duplicate_resources(self, temp_file: Path, caplog):
|
||||
def test_warn_on_duplicate_resources(self, temp_file: Path, caplog: pytest.LogCaptureFixture):
|
||||
"""Test warning on duplicate resources."""
|
||||
manager = ResourceManager()
|
||||
resource = FileResource(
|
||||
|
|
@ -69,7 +64,7 @@ class TestResourceManager:
|
|||
manager.add_resource(resource)
|
||||
assert "Resource already exists" in caplog.text
|
||||
|
||||
def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog):
|
||||
def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog: pytest.LogCaptureFixture):
|
||||
"""Test disabling warning on duplicate resources."""
|
||||
manager = ResourceManager(warn_on_duplicate_resources=False)
|
||||
resource = FileResource(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
|
@ -12,7 +13,7 @@ class TestResourceTemplate:
|
|||
def test_template_creation(self):
|
||||
"""Test creating a template from a function."""
|
||||
|
||||
def my_func(key: str, value: int) -> dict:
|
||||
def my_func(key: str, value: int) -> dict[str, Any]:
|
||||
return {"key": key, "value": value}
|
||||
|
||||
template = ResourceTemplate.from_function(
|
||||
|
|
@ -23,13 +24,12 @@ class TestResourceTemplate:
|
|||
assert template.uri_template == "test://{key}/{value}"
|
||||
assert template.name == "test"
|
||||
assert template.mime_type == "text/plain" # default
|
||||
test_input = {"key": "test", "value": 42}
|
||||
assert template.fn(**test_input) == my_func(**test_input)
|
||||
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:
|
||||
def my_func(key: str, value: int) -> dict[str, Any]:
|
||||
return {"key": key, "value": value}
|
||||
|
||||
template = ResourceTemplate.from_function(
|
||||
|
|
@ -50,7 +50,7 @@ class TestResourceTemplate:
|
|||
async def test_create_resource(self):
|
||||
"""Test creating a resource from a template."""
|
||||
|
||||
def my_func(key: str, value: int) -> dict:
|
||||
def my_func(key: str, value: int) -> dict[str, Any]:
|
||||
return {"key": key, "value": value}
|
||||
|
||||
template = ResourceTemplate.from_function(
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from mcp.server.fastmcp import FastMCP
|
|||
|
||||
|
||||
@pytest.fixture()
|
||||
def test_dir(tmp_path_factory) -> Path:
|
||||
def test_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""Create a temporary directory with test files."""
|
||||
tmp = tmp_path_factory.mktemp("test_files")
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,17 @@
|
|||
Test the elicitation feature using stdio transport.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from mcp.client.session import ClientSession, ElicitationFnT
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.server.session import ServerSession
|
||||
from mcp.shared.context import RequestContext
|
||||
from mcp.shared.memory import create_connected_server_and_client_session
|
||||
from mcp.types import ElicitResult, TextContent
|
||||
from mcp.types import ElicitRequestParams, ElicitResult, TextContent
|
||||
|
||||
|
||||
# Shared schema for basic tests
|
||||
|
|
@ -19,11 +24,8 @@ def create_ask_user_tool(mcp: FastMCP):
|
|||
"""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,
|
||||
)
|
||||
async def ask_user(prompt: str, ctx: Context[ServerSession, None]) -> 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}"
|
||||
|
|
@ -37,9 +39,9 @@ def create_ask_user_tool(mcp: FastMCP):
|
|||
|
||||
async def call_tool_and_assert(
|
||||
mcp: FastMCP,
|
||||
elicitation_callback,
|
||||
elicitation_callback: ElicitationFnT,
|
||||
tool_name: str,
|
||||
args: dict,
|
||||
args: dict[str, Any],
|
||||
expected_text: str | None = None,
|
||||
text_contains: list[str] | None = None,
|
||||
):
|
||||
|
|
@ -69,7 +71,7 @@ async def test_stdio_elicitation():
|
|||
create_ask_user_tool(mcp)
|
||||
|
||||
# Create a custom handler for elicitation requests
|
||||
async def elicitation_callback(context, params):
|
||||
async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams):
|
||||
if params.message == "Tool wants to ask: What is your name?":
|
||||
return ElicitResult(action="accept", content={"answer": "Test User"})
|
||||
else:
|
||||
|
|
@ -86,7 +88,7 @@ async def test_stdio_elicitation_decline():
|
|||
mcp = FastMCP(name="StdioElicitationDeclineServer")
|
||||
create_ask_user_tool(mcp)
|
||||
|
||||
async def elicitation_callback(context, params):
|
||||
async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams):
|
||||
return ElicitResult(action="decline")
|
||||
|
||||
await call_tool_and_assert(
|
||||
|
|
@ -101,7 +103,7 @@ async def test_elicitation_schema_validation():
|
|||
|
||||
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:
|
||||
async def tool(ctx: Context[ServerSession, None]) -> str:
|
||||
try:
|
||||
await ctx.elicit(message="This should fail validation", schema=schema_class)
|
||||
return "Should not reach here"
|
||||
|
|
@ -124,7 +126,7 @@ async def test_elicitation_schema_validation():
|
|||
create_validation_tool("nested_model", InvalidNestedSchema)
|
||||
|
||||
# Dummy callback (won't be called due to validation failure)
|
||||
async def elicitation_callback(context, params):
|
||||
async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams):
|
||||
return ElicitResult(action="accept", content={})
|
||||
|
||||
async with create_connected_server_and_client_session(
|
||||
|
|
@ -153,7 +155,7 @@ async def test_elicitation_with_optional_fields():
|
|||
subscribe: bool | None = Field(default=False, description="Subscribe to newsletter?")
|
||||
|
||||
@mcp.tool(description="Tool with optional fields")
|
||||
async def optional_tool(ctx: Context) -> str:
|
||||
async def optional_tool(ctx: Context[ServerSession, None]) -> str:
|
||||
result = await ctx.elicit(message="Please provide your information", schema=OptionalSchema)
|
||||
|
||||
if result.action == "accept" and result.data:
|
||||
|
|
@ -168,7 +170,7 @@ async def test_elicitation_with_optional_fields():
|
|||
return f"User {result.action}"
|
||||
|
||||
# Test cases with different field combinations
|
||||
test_cases = [
|
||||
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},
|
||||
|
|
@ -183,7 +185,7 @@ async def test_elicitation_with_optional_fields():
|
|||
|
||||
for content, expected in test_cases:
|
||||
|
||||
async def callback(context, params):
|
||||
async def callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams):
|
||||
return ElicitResult(action="accept", content=content)
|
||||
|
||||
await call_tool_and_assert(mcp, callback, "optional_tool", {}, expected)
|
||||
|
|
@ -194,16 +196,19 @@ async def test_elicitation_with_optional_fields():
|
|||
optional_list: list[str] | None = Field(default=None, description="Invalid optional list")
|
||||
|
||||
@mcp.tool(description="Tool with invalid optional field")
|
||||
async def invalid_optional_tool(ctx: Context) -> str:
|
||||
async def invalid_optional_tool(ctx: Context[ServerSession, None]) -> str:
|
||||
try:
|
||||
await ctx.elicit(message="This should fail", schema=InvalidOptionalSchema)
|
||||
return "Should not reach here"
|
||||
except TypeError as e:
|
||||
return f"Validation failed: {str(e)}"
|
||||
|
||||
async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams):
|
||||
return ElicitResult(action="accept", content={})
|
||||
|
||||
await call_tool_and_assert(
|
||||
mcp,
|
||||
lambda c, p: ElicitResult(action="accept", content={}),
|
||||
elicitation_callback,
|
||||
"invalid_optional_tool",
|
||||
{},
|
||||
text_contains=["Validation failed:", "optional_list"],
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
# NOTE: Those were added because we actually want to test wrong type annotations.
|
||||
# pyright: reportUnknownParameterType=false
|
||||
# pyright: reportMissingParameterType=false
|
||||
# pyright: reportUnknownArgumentType=false
|
||||
# pyright: reportUnknownLambdaType=false
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Any, TypedDict
|
||||
|
||||
|
|
@ -58,7 +64,7 @@ def complex_arguments_fn(
|
|||
an_int_with_equals_field: int = Field(1, ge=0),
|
||||
int_annotated_with_default: Annotated[int, Field(description="hey")] = 5,
|
||||
) -> str:
|
||||
_ = (
|
||||
_: Any = (
|
||||
an_int,
|
||||
must_be_none,
|
||||
must_be_none_dumb_annotation,
|
||||
|
|
@ -240,7 +246,7 @@ def test_structured_output_dict_str_types():
|
|||
@pytest.mark.anyio
|
||||
async def test_lambda_function():
|
||||
"""Test lambda function schema and validation"""
|
||||
fn = lambda x, y=5: x # noqa: E731
|
||||
fn: Callable[[str, int], str] = lambda x, y=5: x # noqa: E731
|
||||
meta = func_metadata(lambda x, y=5: x)
|
||||
|
||||
# Test schema
|
||||
|
|
@ -899,7 +905,7 @@ def test_structured_output_unserializable_type_error():
|
|||
class ConfigWithCallable:
|
||||
name: str
|
||||
# Callable defaults are not JSON serializable and will trigger Pydantic warnings
|
||||
callback: Any = lambda x: x * 2
|
||||
callback: Callable[[Any], Any] = lambda x: x * 2
|
||||
|
||||
def func_returning_config_with_callable() -> ConfigWithCallable:
|
||||
return ConfigWithCallable()
|
||||
|
|
@ -955,7 +961,7 @@ def test_structured_output_aliases():
|
|||
|
||||
# Check that the actual output uses aliases too
|
||||
result = ModelWithAliases(**{"first": "hello", "second": "world"})
|
||||
unstructured_content, structured_content = meta.convert_result(result)
|
||||
_, structured_content = meta.convert_result(result)
|
||||
|
||||
# The structured content should use aliases to match the schema
|
||||
assert "first" in structured_content
|
||||
|
|
@ -967,7 +973,7 @@ def test_structured_output_aliases():
|
|||
|
||||
# Also test the case where we have a model with defaults to ensure aliases work in all cases
|
||||
result_with_defaults = ModelWithAliases() # Uses default None values
|
||||
unstructured_content_defaults, structured_content_defaults = meta.convert_result(result_with_defaults)
|
||||
_, structured_content_defaults = meta.convert_result(result_with_defaults)
|
||||
|
||||
# Even with defaults, should use aliases in output
|
||||
assert "first" in structured_content_defaults
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ Integration tests for FastMCP server functionality.
|
|||
These tests validate the proper functioning of FastMCP features using focused,
|
||||
single-feature servers across different transports (SSE and StreamableHTTP).
|
||||
"""
|
||||
# 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 multiprocessing
|
||||
|
|
@ -13,6 +18,7 @@ from collections.abc import Generator
|
|||
|
||||
import pytest
|
||||
import uvicorn
|
||||
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from examples.snippets.servers import (
|
||||
|
|
@ -29,17 +35,27 @@ from examples.snippets.servers import (
|
|||
)
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from mcp.client.streamable_http import GetSessionIdCallback, streamablehttp_client
|
||||
from mcp.shared.context import RequestContext
|
||||
from mcp.shared.message import SessionMessage
|
||||
from mcp.shared.session import RequestResponder
|
||||
from mcp.types import (
|
||||
ClientResult,
|
||||
CreateMessageRequestParams,
|
||||
CreateMessageResult,
|
||||
ElicitRequestParams,
|
||||
ElicitResult,
|
||||
GetPromptResult,
|
||||
InitializeResult,
|
||||
LoggingMessageNotification,
|
||||
LoggingMessageNotificationParams,
|
||||
NotificationParams,
|
||||
ProgressNotification,
|
||||
ProgressNotificationParams,
|
||||
ReadResourceResult,
|
||||
ResourceListChangedNotification,
|
||||
ServerNotification,
|
||||
ServerRequest,
|
||||
TextContent,
|
||||
TextResourceContents,
|
||||
ToolListChangedNotification,
|
||||
|
|
@ -50,12 +66,14 @@ class NotificationCollector:
|
|||
"""Collects notifications from the server for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self.progress_notifications: list = []
|
||||
self.log_messages: list = []
|
||||
self.resource_notifications: list = []
|
||||
self.tool_notifications: list = []
|
||||
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) -> 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):
|
||||
if isinstance(message.root, ProgressNotification):
|
||||
|
|
@ -123,7 +141,7 @@ def run_server_with_transport(module_name: str, port: int, transport: str) -> No
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def server_transport(request, server_port: int) -> Generator[str, None, None]:
|
||||
def server_transport(request: pytest.FixtureRequest, server_port: int) -> Generator[str, None, None]:
|
||||
"""Start server in a separate process with specified MCP instance and transport.
|
||||
|
||||
Args:
|
||||
|
|
@ -177,7 +195,14 @@ def create_client_for_transport(transport: str, server_url: str):
|
|||
raise ValueError(f"Invalid transport: {transport}")
|
||||
|
||||
|
||||
def unpack_streams(client_streams):
|
||||
def unpack_streams(
|
||||
client_streams: tuple[MemoryObjectReceiveStream[SessionMessage | Exception], MemoryObjectSendStream[SessionMessage]]
|
||||
| tuple[
|
||||
MemoryObjectReceiveStream[SessionMessage | Exception],
|
||||
MemoryObjectSendStream[SessionMessage],
|
||||
GetSessionIdCallback,
|
||||
],
|
||||
):
|
||||
"""Unpack client streams handling different return values from SSE vs StreamableHTTP.
|
||||
|
||||
SSE client returns (read_stream, write_stream)
|
||||
|
|
@ -197,7 +222,9 @@ def unpack_streams(client_streams):
|
|||
|
||||
|
||||
# Callback functions for testing
|
||||
async def sampling_callback(context, params) -> CreateMessageResult:
|
||||
async def sampling_callback(
|
||||
context: RequestContext[ClientSession, None], params: CreateMessageRequestParams
|
||||
) -> CreateMessageResult:
|
||||
"""Sampling callback for tests."""
|
||||
return CreateMessageResult(
|
||||
role="assistant",
|
||||
|
|
@ -209,7 +236,7 @@ async def sampling_callback(context, params) -> CreateMessageResult:
|
|||
)
|
||||
|
||||
|
||||
async def elicitation_callback(context, params):
|
||||
async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams):
|
||||
"""Elicitation callback for tests."""
|
||||
# For restaurant booking test
|
||||
if "No tables available" in params.message:
|
||||
|
|
@ -367,7 +394,7 @@ async def test_tool_progress(server_transport: str, server_url: str) -> None:
|
|||
transport = server_transport
|
||||
collector = NotificationCollector()
|
||||
|
||||
async def message_handler(message):
|
||||
async def message_handler(message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception):
|
||||
await collector.handle_generic_notification(message)
|
||||
if isinstance(message, Exception):
|
||||
raise message
|
||||
|
|
@ -508,7 +535,7 @@ async def test_notifications(server_transport: str, server_url: str) -> None:
|
|||
transport = server_transport
|
||||
collector = NotificationCollector()
|
||||
|
||||
async def message_handler(message):
|
||||
async def message_handler(message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception):
|
||||
await collector.handle_generic_notification(message)
|
||||
if isinstance(message, Exception):
|
||||
raise message
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from mcp.server.fastmcp import Context, FastMCP
|
|||
from mcp.server.fastmcp.prompts.base import Message, UserMessage
|
||||
from mcp.server.fastmcp.resources import FileResource, FunctionResource
|
||||
from mcp.server.fastmcp.utilities.types import Image
|
||||
from mcp.server.session import ServerSession
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.shared.memory import (
|
||||
create_connected_server_and_client_session as client_session,
|
||||
|
|
@ -338,8 +339,10 @@ class TestServerTools:
|
|||
image_path = tmp_path / "test.png"
|
||||
image_path.write_bytes(b"test image data")
|
||||
|
||||
def mixed_list_fn() -> list:
|
||||
return [
|
||||
# TODO(Marcelo): It seems if we add the proper type hint, it generates an invalid JSON schema.
|
||||
# We need to fix this.
|
||||
def mixed_list_fn() -> list: # type: ignore
|
||||
return [ # type: ignore
|
||||
"text message",
|
||||
Image(image_path),
|
||||
{"key": "value"},
|
||||
|
|
@ -347,7 +350,7 @@ class TestServerTools:
|
|||
]
|
||||
|
||||
mcp = FastMCP()
|
||||
mcp.add_tool(mixed_list_fn)
|
||||
mcp.add_tool(mixed_list_fn) # type: ignore
|
||||
async with client_session(mcp._mcp_server) as client:
|
||||
result = await client.call_tool("mixed_list_fn", {})
|
||||
assert len(result.content) == 4
|
||||
|
|
@ -655,7 +658,7 @@ class TestServerResourceTemplates:
|
|||
mcp = FastMCP()
|
||||
|
||||
@mcp.resource("resource://{param}")
|
||||
def get_data(param) -> str:
|
||||
def get_data(param) -> str: # type: ignore
|
||||
return "Data"
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
@ -748,7 +751,7 @@ class TestContextInjection:
|
|||
"""Test that context parameters are properly detected."""
|
||||
mcp = FastMCP()
|
||||
|
||||
def tool_with_context(x: int, ctx: Context) -> str:
|
||||
def tool_with_context(x: int, ctx: Context[ServerSession, None]) -> str:
|
||||
return f"Request {ctx.request_id}: {x}"
|
||||
|
||||
tool = mcp._tool_manager.add_tool(tool_with_context)
|
||||
|
|
@ -759,7 +762,7 @@ class TestContextInjection:
|
|||
"""Test that context is properly injected into tool calls."""
|
||||
mcp = FastMCP()
|
||||
|
||||
def tool_with_context(x: int, ctx: Context) -> str:
|
||||
def tool_with_context(x: int, ctx: Context[ServerSession, None]) -> str:
|
||||
assert ctx.request_id is not None
|
||||
return f"Request {ctx.request_id}: {x}"
|
||||
|
||||
|
|
@ -777,7 +780,7 @@ class TestContextInjection:
|
|||
"""Test that context works in async functions."""
|
||||
mcp = FastMCP()
|
||||
|
||||
async def async_tool(x: int, ctx: Context) -> str:
|
||||
async def async_tool(x: int, ctx: Context[ServerSession, None]) -> str:
|
||||
assert ctx.request_id is not None
|
||||
return f"Async request {ctx.request_id}: {x}"
|
||||
|
||||
|
|
@ -792,12 +795,10 @@ class TestContextInjection:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_logging(self):
|
||||
import mcp.server.session
|
||||
|
||||
"""Test that context logging methods work."""
|
||||
mcp = FastMCP()
|
||||
|
||||
async def logging_tool(msg: str, ctx: Context) -> str:
|
||||
async def logging_tool(msg: str, ctx: Context[ServerSession, None]) -> str:
|
||||
await ctx.debug("Debug message")
|
||||
await ctx.info("Info message")
|
||||
await ctx.warning("Warning message")
|
||||
|
|
@ -866,7 +867,7 @@ class TestContextInjection:
|
|||
return "resource data"
|
||||
|
||||
@mcp.tool()
|
||||
async def tool_with_resource(ctx: Context) -> str:
|
||||
async def tool_with_resource(ctx: Context[ServerSession, None]) -> str:
|
||||
r_iter = await ctx.read_resource("test://data")
|
||||
r_list = list(r_iter)
|
||||
assert len(r_list) == 1
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ class TestAddTools:
|
|||
assert tool.parameters["properties"]["a"]["type"] == "integer"
|
||||
assert tool.parameters["properties"]["b"]["type"] == "integer"
|
||||
|
||||
def test_init_with_tools(self, caplog):
|
||||
def test_init_with_tools(self, caplog: pytest.LogCaptureFixture):
|
||||
def sum(a: int, b: int) -> int:
|
||||
return a + b
|
||||
|
||||
|
|
@ -89,7 +89,7 @@ class TestAddTools:
|
|||
name: str
|
||||
age: int
|
||||
|
||||
def create_user(user: UserInput, flag: bool) -> dict:
|
||||
def create_user(user: UserInput, flag: bool) -> dict[str, Any]:
|
||||
"""Create a new user."""
|
||||
return {"id": 1, **user.model_dump()}
|
||||
|
||||
|
|
@ -145,15 +145,15 @@ class TestAddTools:
|
|||
|
||||
def test_add_lambda(self):
|
||||
manager = ToolManager()
|
||||
tool = manager.add_tool(lambda x: x, name="my_tool")
|
||||
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)
|
||||
manager.add_tool(lambda x: x) # type: ignore[reportUnknownLambdaType]
|
||||
|
||||
def test_warn_on_duplicate_tools(self, caplog):
|
||||
def test_warn_on_duplicate_tools(self, caplog: pytest.LogCaptureFixture):
|
||||
"""Test warning on duplicate tools."""
|
||||
|
||||
def f(x: int) -> int:
|
||||
|
|
@ -165,7 +165,7 @@ class TestAddTools:
|
|||
manager.add_tool(f)
|
||||
assert "Tool already exists: f" in caplog.text
|
||||
|
||||
def test_disable_warn_on_duplicate_tools(self, caplog):
|
||||
def test_disable_warn_on_duplicate_tools(self, caplog: pytest.LogCaptureFixture):
|
||||
"""Test disabling warning on duplicate tools."""
|
||||
|
||||
def f(x: int) -> int:
|
||||
|
|
@ -297,7 +297,7 @@ class TestCallTools:
|
|||
shrimp: list[Shrimp]
|
||||
x: None
|
||||
|
||||
def name_shrimp(tank: MyShrimpTank, ctx: Context) -> list[str]:
|
||||
def name_shrimp(tank: MyShrimpTank, ctx: Context[ServerSessionT, None]) -> list[str]:
|
||||
return [x.name for x in tank.shrimp]
|
||||
|
||||
manager = ToolManager()
|
||||
|
|
@ -317,7 +317,7 @@ class TestCallTools:
|
|||
class TestToolSchema:
|
||||
@pytest.mark.anyio
|
||||
async def test_context_arg_excluded_from_schema(self):
|
||||
def something(a: int, ctx: Context) -> int:
|
||||
def something(a: int, ctx: Context[ServerSessionT, None]) -> int:
|
||||
return a
|
||||
|
||||
manager = ToolManager()
|
||||
|
|
@ -334,7 +334,7 @@ class TestContextHandling:
|
|||
"""Test that context parameters are properly detected in
|
||||
Tool.from_function()."""
|
||||
|
||||
def tool_with_context(x: int, ctx: Context) -> str:
|
||||
def tool_with_context(x: int, ctx: Context[ServerSessionT, None]) -> str:
|
||||
return str(x)
|
||||
|
||||
manager = ToolManager()
|
||||
|
|
@ -357,7 +357,7 @@ class TestContextHandling:
|
|||
async def test_context_injection(self):
|
||||
"""Test that context is properly injected during tool execution."""
|
||||
|
||||
def tool_with_context(x: int, ctx: Context) -> str:
|
||||
def tool_with_context(x: int, ctx: Context[ServerSessionT, None]) -> str:
|
||||
assert isinstance(ctx, Context)
|
||||
return str(x)
|
||||
|
||||
|
|
@ -373,7 +373,7 @@ class TestContextHandling:
|
|||
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:
|
||||
async def async_tool(x: int, ctx: Context[ServerSessionT, None]) -> str:
|
||||
assert isinstance(ctx, Context)
|
||||
return str(x)
|
||||
|
||||
|
|
@ -389,7 +389,7 @@ class TestContextHandling:
|
|||
async def test_context_optional(self):
|
||||
"""Test that context is optional when calling tools."""
|
||||
|
||||
def tool_with_context(x: int, ctx: Context | None = None) -> str:
|
||||
def tool_with_context(x: int, ctx: Context[ServerSessionT, None] | None = None) -> str:
|
||||
return str(x)
|
||||
|
||||
manager = ToolManager()
|
||||
|
|
@ -402,7 +402,7 @@ class TestContextHandling:
|
|||
async def test_context_error_handling(self):
|
||||
"""Test error handling when context injection fails."""
|
||||
|
||||
def tool_with_context(x: int, ctx: Context) -> str:
|
||||
def tool_with_context(x: int, ctx: Context[ServerSessionT, None]) -> str:
|
||||
raise ValueError("Test error")
|
||||
|
||||
manager = ToolManager()
|
||||
|
|
@ -552,7 +552,7 @@ class TestStructuredOutput:
|
|||
async def test_tool_without_structured_output(self):
|
||||
"""Test that tools work normally when structured_output=False."""
|
||||
|
||||
def get_dict() -> dict:
|
||||
def get_dict() -> dict[str, Any]:
|
||||
"""Get a dict."""
|
||||
return {"key": "value"}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
"""Test that cancelled requests don't cause double responses."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
|
|
@ -41,7 +43,7 @@ async def test_server_remains_functional_after_cancel():
|
|||
]
|
||||
|
||||
@server.call_tool()
|
||||
async def handle_call_tool(name: str, arguments: dict | None) -> list:
|
||||
async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[types.TextContent]:
|
||||
nonlocal call_count, first_request_id
|
||||
if name == "test_tool":
|
||||
call_count += 1
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
Tests for completion handler with context functionality.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp.server.lowlevel import Server
|
||||
|
|
@ -21,7 +23,7 @@ async def test_completion_handler_receives_context():
|
|||
server = Server("test-server")
|
||||
|
||||
# Track what the handler receives
|
||||
received_args = {}
|
||||
received_args: dict[str, Any] = {}
|
||||
|
||||
@server.completion()
|
||||
async def handle_completion(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
|
@ -10,6 +11,7 @@ from pydantic import TypeAdapter
|
|||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.server.lowlevel.server import NotificationOptions, Server
|
||||
from mcp.server.models import InitializationOptions
|
||||
from mcp.server.session import ServerSession
|
||||
from mcp.shared.message import SessionMessage
|
||||
from mcp.types import (
|
||||
ClientCapabilities,
|
||||
|
|
@ -18,6 +20,8 @@ from mcp.types import (
|
|||
JSONRPCMessage,
|
||||
JSONRPCNotification,
|
||||
JSONRPCRequest,
|
||||
JSONRPCResponse,
|
||||
TextContent,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -35,29 +39,23 @@ async def test_lowlevel_server_lifespan():
|
|||
finally:
|
||||
context["shutdown"] = True
|
||||
|
||||
server = Server("test", lifespan=test_lifespan)
|
||||
server = Server[dict[str, bool]]("test", lifespan=test_lifespan)
|
||||
|
||||
# Create memory streams for testing
|
||||
send_stream1, receive_stream1 = anyio.create_memory_object_stream(100)
|
||||
send_stream2, receive_stream2 = anyio.create_memory_object_stream(100)
|
||||
send_stream1, receive_stream1 = anyio.create_memory_object_stream[SessionMessage](100)
|
||||
send_stream2, receive_stream2 = anyio.create_memory_object_stream[SessionMessage](100)
|
||||
|
||||
# Create a tool that accesses lifespan context
|
||||
@server.call_tool()
|
||||
async def check_lifespan(name: str, arguments: dict) -> list:
|
||||
async def check_lifespan(name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
||||
ctx = server.request_context
|
||||
assert isinstance(ctx.lifespan_context, dict)
|
||||
assert ctx.lifespan_context["started"]
|
||||
assert not ctx.lifespan_context["shutdown"]
|
||||
return [{"type": "text", "text": "true"}]
|
||||
return [TextContent(type="text", text="true")]
|
||||
|
||||
# Run server in background task
|
||||
async with (
|
||||
anyio.create_task_group() as tg,
|
||||
send_stream1,
|
||||
receive_stream1,
|
||||
send_stream2,
|
||||
receive_stream2,
|
||||
):
|
||||
async with anyio.create_task_group() as tg, send_stream1, receive_stream1, send_stream2, receive_stream2:
|
||||
|
||||
async def run_server():
|
||||
await server.run(
|
||||
|
|
@ -126,6 +124,8 @@ async def test_lowlevel_server_lifespan():
|
|||
# Get response and verify
|
||||
response = await receive_stream2.receive()
|
||||
response = response.message
|
||||
assert isinstance(response, JSONRPCMessage)
|
||||
assert isinstance(response.root, JSONRPCResponse)
|
||||
assert response.root.result["content"][0]["text"] == "true"
|
||||
|
||||
# Cancel server task
|
||||
|
|
@ -137,7 +137,7 @@ async def test_fastmcp_server_lifespan():
|
|||
"""Test that lifespan works in FastMCP server."""
|
||||
|
||||
@asynccontextmanager
|
||||
async def test_lifespan(server: FastMCP) -> AsyncIterator[dict]:
|
||||
async def test_lifespan(server: FastMCP) -> AsyncIterator[dict[str, bool]]:
|
||||
"""Test lifespan context that tracks startup/shutdown."""
|
||||
context = {"started": False, "shutdown": False}
|
||||
try:
|
||||
|
|
@ -149,12 +149,12 @@ async def test_fastmcp_server_lifespan():
|
|||
server = FastMCP("test", lifespan=test_lifespan)
|
||||
|
||||
# Create memory streams for testing
|
||||
send_stream1, receive_stream1 = anyio.create_memory_object_stream(100)
|
||||
send_stream2, receive_stream2 = anyio.create_memory_object_stream(100)
|
||||
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:
|
||||
def check_lifespan(ctx: Context[ServerSession, None]) -> bool:
|
||||
"""Tool that checks lifespan context."""
|
||||
assert isinstance(ctx.request_context.lifespan_context, dict)
|
||||
assert ctx.request_context.lifespan_context["started"]
|
||||
|
|
@ -230,6 +230,8 @@ async def test_fastmcp_server_lifespan():
|
|||
# Get response and verify
|
||||
response = await receive_stream2.receive()
|
||||
response = response.message
|
||||
assert isinstance(response, JSONRPCMessage)
|
||||
assert isinstance(response.root, JSONRPCResponse)
|
||||
assert response.root.result["content"][0]["text"] == "true"
|
||||
|
||||
# Cancel server task
|
||||
|
|
|
|||
|
|
@ -263,7 +263,7 @@ async def test_enum_constraint_validation():
|
|||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_not_in_list_logs_warning(caplog):
|
||||
async def test_tool_not_in_list_logs_warning(caplog: pytest.LogCaptureFixture):
|
||||
"""Test that calling a tool not in list_tools logs a warning and skips validation."""
|
||||
tools = [
|
||||
Tool(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
|
|
@ -16,8 +18,10 @@ from mcp.types import (
|
|||
CompletionContext,
|
||||
CompletionsCapability,
|
||||
InitializedNotification,
|
||||
Prompt,
|
||||
PromptReference,
|
||||
PromptsCapability,
|
||||
Resource,
|
||||
ResourcesCapability,
|
||||
ResourceTemplateReference,
|
||||
ServerCapabilities,
|
||||
|
|
@ -80,7 +84,7 @@ async def test_server_session_initialize():
|
|||
async def test_server_capabilities():
|
||||
server = Server("test")
|
||||
notification_options = NotificationOptions()
|
||||
experimental_capabilities = {}
|
||||
experimental_capabilities: dict[str, Any] = {}
|
||||
|
||||
# Initially no capabilities
|
||||
caps = server.get_capabilities(notification_options, experimental_capabilities)
|
||||
|
|
@ -90,7 +94,7 @@ async def test_server_capabilities():
|
|||
|
||||
# Add a prompts handler
|
||||
@server.list_prompts()
|
||||
async def list_prompts():
|
||||
async def list_prompts() -> list[Prompt]:
|
||||
return []
|
||||
|
||||
caps = server.get_capabilities(notification_options, experimental_capabilities)
|
||||
|
|
@ -100,7 +104,7 @@ async def test_server_capabilities():
|
|||
|
||||
# Add a resources handler
|
||||
@server.list_resources()
|
||||
async def list_resources():
|
||||
async def list_resources() -> list[Resource]:
|
||||
return []
|
||||
|
||||
caps = server.get_capabilities(notification_options, experimental_capabilities)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ async def test_stdio_server():
|
|||
read_stream,
|
||||
write_stream,
|
||||
):
|
||||
received_messages = []
|
||||
received_messages: list[JSONRPCMessage] = []
|
||||
async with read_stream:
|
||||
async for message in read_stream:
|
||||
if isinstance(message, Exception):
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue