From 1268fe19997316b83305153c2cfebc772d25f13a Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Fri, 2 May 2025 20:19:15 -0700 Subject: [PATCH 1/2] refactor: use context var and init whoami sample --- .github/workflows/main.yml | 2 +- .python-version | 2 +- mcpauth/__init__.py | 107 ++++++-- mcpauth/config.py | 5 + mcpauth/exceptions.py | 24 +- mcpauth/middleware/create_bearer_auth.py | 29 ++- mcpauth/types.py | 91 ++++++- mcpauth/utils/_create_verify_jwt.py | 34 +-- pyproject.toml | 3 +- samples/server/starlette.py | 33 --- samples/server/whoami.py | 86 +++++++ tests/__init__test.py | 71 ++++-- tests/exceptions_test.py | 24 +- tests/middleware/create_bearer_auth_test.py | 129 ++++++---- tests/utils/create_verify_jwt_test.py | 26 +- uv.lock | 257 ++++++++++++++------ 16 files changed, 650 insertions(+), 273 deletions(-) delete mode 100644 samples/server/starlette.py create mode 100644 samples/server/whoami.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 971ff03..5a396fa 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,7 +10,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13"] os: [ubuntu-latest] runs-on: ${{ matrix.os }} diff --git a/.python-version b/.python-version index bd28b9c..c8cfe39 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.9 +3.10 diff --git a/mcpauth/__init__.py b/mcpauth/__init__.py index 8168ae2..bfa684a 100644 --- a/mcpauth/__init__.py +++ b/mcpauth/__init__.py @@ -1,13 +1,18 @@ +from contextvars import ContextVar import logging -from typing import List, Literal, Optional, Union +from typing import Any, Callable, List, Literal, Optional, Union from .middleware.create_bearer_auth import BearerAuthConfig -from .types import VerifyAccessTokenFunction -from .config import AuthServerConfig +from .types import AuthInfo, VerifyAccessTokenFunction +from .config import AuthServerConfig, ServerMetadataPaths from .exceptions import MCPAuthAuthServerException, AuthServerExceptionCode from .utils import validate_server_config from starlette.middleware.base import BaseHTTPMiddleware -from starlette.responses import JSONResponse +from starlette.responses import Response, JSONResponse +from starlette.requests import Request +from starlette.routing import Route + +_context_var_name = "mcp_auth_context" class MCPAuth: @@ -18,9 +23,22 @@ class MCPAuth: See Also: https://mcp-auth.dev for more information about the library and its usage. """ - def __init__(self, server: AuthServerConfig): + server: AuthServerConfig + """ + The configuration for the remote authorization server. + """ + + def __init__( + self, + server: AuthServerConfig, + context_var: ContextVar[Optional[AuthInfo]] = ContextVar( + _context_var_name, default=None + ), + ): """ :param server: Configuration for the remote authorization server. + :param context_var: Context variable to store the `AuthInfo` object for the current request. + By default, it will be created with the name "mcp_auth_context". """ result = validate_server_config(server) @@ -40,20 +58,78 @@ def __init__(self, server: AuthServerConfig): logging.warning(f"- {warning}") self.server = server + self._context_var = context_var + + @property + def auth_info(self) -> Optional[AuthInfo]: + """ + The current `AuthInfo` object from the context variable. + + This is useful for accessing the authenticated user's information in later middleware or + route handlers. + :return: The current `AuthInfo` object, or `None` if not set. + """ + + return self._context_var.get() - def metadata_response(self) -> JSONResponse: + def metadata_endpoint(self) -> Callable[[Request], Any]: """ - Returns a response containing the server metadata in JSON format with CORS support. + Returns a Starlette endpoint function that handles the OAuth 2.0 Authorization Metadata + endpoint (`/.well-known/oauth-authorization-server`) with CORS support. + + Example: + ```python + from starlette.applications import Starlette + from mcpauth import MCPAuth + from mcpauth.config import ServerMetadataPaths + + mcp_auth = MCPAuth(server=your_server_config) + app = Starlette(routes=[ + Route( + ServerMetadataPaths.OAUTH.value, + mcp_auth.metadata_endpoint(), + methods=["GET", "OPTIONS"] # Ensure to handle both GET and OPTIONS methods + ) + ]) + ``` + """ + + async def endpoint(request: Request) -> Response: + if request.method == "OPTIONS": + response = Response(status_code=204) + else: + server_config = self.server + response = JSONResponse( + server_config.metadata.model_dump(exclude_none=True), + status_code=200, + ) + response.headers["Access-Control-Allow-Origin"] = "*" + response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS" + response.headers["Access-Control-Allow-Headers"] = "*" + return response + + return endpoint + + def metadata_route(self) -> Route: + """ + Returns a Starlette route that handles the OAuth 2.0 Authorization Metadata endpoint + (`/.well-known/oauth-authorization-server`) with CORS support. + + Example: + ```python + from starlette.applications import Starlette + from mcpauth import MCPAuth + + mcp_auth = MCPAuth(server=your_server_config) + app = Starlette(routes=[mcp_auth.metadata_route()]) + ``` """ - server_config = self.server - response = JSONResponse( - server_config.metadata.model_dump(exclude_none=True), - status_code=200, + return Route( + ServerMetadataPaths.OAUTH.value, + self.metadata_endpoint(), + methods=["GET", "OPTIONS"], ) - response.headers["Access-Control-Allow-Origin"] = "*" - response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS" - return response def bearer_auth_middleware( self, @@ -101,10 +177,11 @@ def bearer_auth_middleware( return create_bearer_auth( verify, - BearerAuthConfig( + config=BearerAuthConfig( issuer=metadata.issuer, audience=audience, required_scopes=required_scopes, show_error_details=show_error_details, ), + context_var=self._context_var, ) diff --git a/mcpauth/config.py b/mcpauth/config.py index f50c249..7c726da 100644 --- a/mcpauth/config.py +++ b/mcpauth/config.py @@ -100,6 +100,11 @@ class AuthorizationServerMetadata(BaseModel): code challenge methods supported by this authorization server. """ + userinfo_endpoint: Optional[str] = None + """ + URL of the authorization server's UserInfo endpoint [[OpenID Connect](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo)]. + """ + class AuthServerType(str, Enum): """ diff --git a/mcpauth/exceptions.py b/mcpauth/exceptions.py index 6f17b44..bf1f759 100644 --- a/mcpauth/exceptions.py +++ b/mcpauth/exceptions.py @@ -143,31 +143,31 @@ def to_json(self, show_cause: bool = False) -> Dict[str, Optional[str]]: return {k: v for k, v in data.items() if v is not None} -class MCPAuthJwtVerificationExceptionCode(str, Enum): - INVALID_JWT = "invalid_jwt" - JWT_VERIFICATION_FAILED = "jwt_verification_failed" +class MCPAuthTokenVerificationExceptionCode(str, Enum): + INVALID_TOKEN = "invalid_token" + TOKEN_VERIFICATION_FAILED = "token_verification_failed" -jwt_verification_exception_description: Dict[ - MCPAuthJwtVerificationExceptionCode, str +token_verification_exception_description: Dict[ + MCPAuthTokenVerificationExceptionCode, str ] = { - MCPAuthJwtVerificationExceptionCode.INVALID_JWT: "The provided JWT is invalid or malformed.", - MCPAuthJwtVerificationExceptionCode.JWT_VERIFICATION_FAILED: "JWT verification failed. The token could not be verified.", + MCPAuthTokenVerificationExceptionCode.INVALID_TOKEN: "The provided token is invalid or malformed.", + MCPAuthTokenVerificationExceptionCode.TOKEN_VERIFICATION_FAILED: "The token verification failed due to an error in the verification process.", } -class MCPAuthJwtVerificationException(MCPAuthException): +class MCPAuthTokenVerificationException(MCPAuthException): """ - Exception thrown when there is an issue when verifying JWT tokens. + Exception thrown when there is an issue when verifying access tokens. """ def __init__( - self, code: MCPAuthJwtVerificationExceptionCode, cause: ExceptionCause = None + self, code: MCPAuthTokenVerificationExceptionCode, cause: ExceptionCause = None ): super().__init__( code.value, - jwt_verification_exception_description.get( - code, "An exception occurred while verifying the JWT." + token_verification_exception_description.get( + code, "An exception occurred while verifying the token." ), ) self.code = code diff --git a/mcpauth/middleware/create_bearer_auth.py b/mcpauth/middleware/create_bearer_auth.py index 639d6cf..e281c4d 100644 --- a/mcpauth/middleware/create_bearer_auth.py +++ b/mcpauth/middleware/create_bearer_auth.py @@ -1,3 +1,4 @@ +from contextvars import ContextVar from typing import Any, Dict, List, Optional from urllib.parse import urlparse import logging @@ -9,13 +10,13 @@ from ..exceptions import ( MCPAuthBearerAuthException, - MCPAuthJwtVerificationException, + MCPAuthTokenVerificationException, MCPAuthAuthServerException, MCPAuthConfigException, BearerAuthExceptionCode, MCPAuthBearerAuthExceptionDetails, ) -from ..types import VerifyAccessTokenFunction, Record +from ..types import AuthInfo, VerifyAccessTokenFunction, Record class BearerAuthConfig(BaseModel): @@ -92,7 +93,7 @@ def _handle_error( Returns: A tuple of (status_code, response_body). """ - if isinstance(error, MCPAuthJwtVerificationException): + if isinstance(error, MCPAuthTokenVerificationException): return 401, error.to_json(show_error_details) if isinstance(error, MCPAuthBearerAuthException): @@ -114,7 +115,9 @@ def _handle_error( def create_bearer_auth( - verify_access_token: VerifyAccessTokenFunction, config: BearerAuthConfig + verify_access_token: VerifyAccessTokenFunction, + config: BearerAuthConfig, + context_var: ContextVar[Optional[AuthInfo]], ) -> type[BaseHTTPMiddleware]: """ Creates a middleware function for handling Bearer auth. @@ -122,12 +125,12 @@ def create_bearer_auth( This middleware extracts the Bearer token from the `Authorization` header, verifies it using the provided `verify_access_token` function, and checks the issuer, audience, and required scopes. - Args: - verify_access_token: A function that takes a Bearer token and returns an `AuthInfo` object. - config: Configuration for the Bearer auth handler. + :param verify_access_token: A function that takes a Bearer token and returns an `AuthInfo` object. + :param config: Configuration for the Bearer auth handler. + :param context_var: Context variable to store the `AuthInfo` object for the current request. + This allows access to the authenticated user's information in later middleware or route handlers. - Returns: - A middleware class that handles Bearer auth. + :return: A middleware class that handles Bearer auth. """ if not callable(verify_access_token): @@ -206,8 +209,12 @@ async def dispatch( cause=details, ) - # Attach auth info to the request - request.state.auth = auth_info + if context_var.get() is not None: + logging.warning( + "Overwriting existing auth info in context variable." + ) + + context_var.set(auth_info) # Call the next middleware or route handler response = await call_next(request) diff --git a/mcpauth/types.py b/mcpauth/types.py index e88bc3c..b1844dd 100644 --- a/mcpauth/types.py +++ b/mcpauth/types.py @@ -1,5 +1,5 @@ -from typing import Dict, List, Optional, Protocol, Union, Any -from pydantic import BaseModel +from typing import Annotated, Dict, List, Optional, Protocol, Union, Any +from pydantic import BaseModel, StringConstraints Record = Dict[str, Any] @@ -29,7 +29,7 @@ class AuthInfo(BaseModel): - https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier """ - client_id: str + client_id: Optional[str] = None """ The client ID of the OAuth client that the token was issued to. This is typically the client ID registered with the OAuth / OIDC provider. @@ -37,7 +37,7 @@ class AuthInfo(BaseModel): Some providers may use 'application ID' or similar terms instead of 'client ID'. """ - scopes: List[str] + scopes: List[str] = [] """ The scopes (permissions) that the access token has been granted. Scopes define what actions the token can perform on behalf of the user or client. Normally, you need to define these scopes in @@ -47,12 +47,7 @@ class AuthInfo(BaseModel): role-based access control (RBAC) or fine-grained permissions. """ - expires_at: Optional[int] - """ - The expiration time of the access token, represented as a Unix timestamp (seconds since epoch). - """ - - subject: Optional[str] + subject: str """ The `sub` (subject) claim of the token, which typically represents the user ID or principal that the token is issued for. @@ -61,7 +56,7 @@ class AuthInfo(BaseModel): - https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.2 """ - audience: Optional[Union[str, List[str]]] + audience: Optional[Union[str, List[str]]] = None """ The `aud` (audience) claim of the token, which indicates the intended recipient(s) of the token. @@ -86,7 +81,7 @@ class VerifyAccessTokenFunction(Protocol): """ Function type for verifying an access token. - This function should throw an `MCPAuthJwtVerificationException` if the token is invalid, or return an + This function should throw an `MCPAuthTokenVerificationException` if the token is invalid, or return an `AuthInfo` instance if the token is valid. For example, if you have a JWT verification function, it should at least check the token's @@ -108,3 +103,75 @@ def __call__(self, token: str) -> AuthInfo: :return: An `AuthInfo` instance containing the extracted authentication information. """ ... + + +NonEmptyString = Annotated[str, StringConstraints(min_length=1)] + + +class JwtPayload(BaseModel): + """ + The base model for JWT (JSON Web Token) payload claims. + This model defines the common claims that are expected in a JWT used for authentication and + authorization. + """ + + aud: Optional[Union[NonEmptyString, List[NonEmptyString]]] = None + """ + The `aud` (audience) claim of the token, which indicates the intended recipient(s) of the token. + + For OAuth / OIDC providers that support Resource Indicators (RFC 8707), this claim can be used + to specify the intended Resource Server (API) that the token is meant for. + + If the token is intended for multiple audiences, this can be a list of strings. + + See Also: + - https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3 + - https://datatracker.ietf.org/doc/html/rfc8707 + """ + + iss: NonEmptyString + """ + The issuer of the access token, which is typically the OAuth / OIDC provider that issued the token. + This is usually a URL that identifies the authorization server. + + See Also: + - https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1 + - https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier + """ + + client_id: NonEmptyString + """ + The client ID of the OAuth client that the token was issued to. This is typically the client ID + registered with the OAuth / OIDC provider. + + Some providers may use 'application ID' or similar terms instead of 'client ID'. + """ + + sub: NonEmptyString + """ + The `sub` (subject) claim of the token, which typically represents the user ID or principal + that the token is issued for. + + See Also: + - https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.2 + """ + + scope: Optional[Union[str, List[str]]] = None + """ + The scopes (permissions) that the access token has been granted. Scopes define what actions the + token can perform on behalf of the user or client. Normally, you need to define these scopes in + the OAuth / OIDC provider and assign them to the `subject` of the token. + + The provider may support different mechanisms for defining and managing scopes, such as + role-based access control (RBAC) or fine-grained permissions. + """ + + scopes: Optional[Union[str, List[str]]] = None + """ + The fallback for the `scope` claim. + """ + + exp: Optional[int] = None + """ + The expiration time of the access token, represented as a Unix timestamp (seconds since epoch). + """ diff --git a/mcpauth/utils/_create_verify_jwt.py b/mcpauth/utils/_create_verify_jwt.py index cd6e2e8..2680d62 100644 --- a/mcpauth/utils/_create_verify_jwt.py +++ b/mcpauth/utils/_create_verify_jwt.py @@ -1,24 +1,12 @@ -from typing import Annotated, List, Optional, Union +from typing import List, Union from jwt import PyJWK, PyJWKClient, PyJWTError, decode -from pydantic import BaseModel, StringConstraints, ValidationError -from ..types import AuthInfo, VerifyAccessTokenFunction +from pydantic import ValidationError +from ..types import AuthInfo, JwtPayload, VerifyAccessTokenFunction from ..exceptions import ( - MCPAuthJwtVerificationException, - MCPAuthJwtVerificationExceptionCode, + MCPAuthTokenVerificationException, + MCPAuthTokenVerificationExceptionCode, ) -NonEmptyString = Annotated[str, StringConstraints(min_length=1)] - - -class JwtBaseModel(BaseModel): - aud: Optional[Union[NonEmptyString, List[NonEmptyString]]] = None - iss: NonEmptyString - client_id: NonEmptyString - sub: NonEmptyString - scope: Optional[Union[str, List[str]]] = None - scopes: Optional[Union[str, List[str]]] = None - exp: Optional[int] = None - def create_verify_jwt( input: Union[str, PyJWKClient, PyJWK], @@ -35,6 +23,7 @@ def create_verify_jwt( :param algorithms: A list of acceptable algorithms for verifying the JWT signature. :param leeway: The amount of leeway (in seconds) to allow when checking the expiration time of the JWT. :return: A function that can be used to verify JWTs. + :raises MCPAuthTokenVerificationException: If the JWT verification fails. """ jwks = ( @@ -66,7 +55,7 @@ def verify_jwt(token: str) -> AuthInfo: "verify_iss": False, }, ) - base_model = JwtBaseModel(**decoded) + base_model = JwtPayload(**decoded) scopes = base_model.scope or base_model.scopes return AuthInfo( token=token, @@ -75,17 +64,16 @@ def verify_jwt(token: str) -> AuthInfo: subject=base_model.sub, audience=base_model.aud, scopes=(scopes.split(" ") if isinstance(scopes, str) else scopes) or [], - expires_at=base_model.exp, claims=decoded, ) except (PyJWTError, ValidationError) as e: - raise MCPAuthJwtVerificationException( - MCPAuthJwtVerificationExceptionCode.INVALID_JWT, + raise MCPAuthTokenVerificationException( + MCPAuthTokenVerificationExceptionCode.INVALID_TOKEN, cause=e, ) except Exception as e: - raise MCPAuthJwtVerificationException( - MCPAuthJwtVerificationExceptionCode.JWT_VERIFICATION_FAILED, + raise MCPAuthTokenVerificationException( + MCPAuthTokenVerificationExceptionCode.TOKEN_VERIFICATION_FAILED, cause=e, ) diff --git a/pyproject.toml b/pyproject.toml index d623074..ff91942 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "0.0.0" description = "Plug-and-play auth for Python MCP servers." authors = [{ name = "Silverhand Inc.", email = "contact@silverhand.io" }] readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" license = "MIT" keywords = [ "authentication", @@ -30,6 +30,7 @@ documentation = "https://mcp-auth.dev/docs" [dependency-groups] dev = [ "black>=24.8.0", + "mcp[cli]>=1.7.1", "pytest>=8.3.5", "pytest-asyncio>=0.26.0", "pytest-cov>=6.1.1", diff --git a/samples/server/starlette.py b/samples/server/starlette.py deleted file mode 100644 index ea34bf1..0000000 --- a/samples/server/starlette.py +++ /dev/null @@ -1,33 +0,0 @@ -from mcpauth import MCPAuth -from mcpauth.config import AuthServerType, ServerMetadataPaths -from mcpauth.utils import fetch_server_config -from starlette.applications import Starlette -from starlette.middleware import Middleware -from starlette.responses import JSONResponse -from starlette.requests import Request -from starlette.routing import Route -import os - -MCP_AUTH_ISSUER = ( - os.getenv("MCP_AUTH_ISSUER") or "https://replace-with-your-issuer-url.com" -) - -mcp_auth = MCPAuth(server=fetch_server_config(MCP_AUTH_ISSUER, AuthServerType.OIDC)) - - -async def mcp_endpoint(request: Request): - return JSONResponse({"auth": request.state.auth}) - - -protected_app = Starlette( - middleware=[ - Middleware(mcp_auth.bearer_auth_middleware("jwt", required_scopes=["read"])) - ], - routes=[Route("/", endpoint=mcp_endpoint)], -) - -app = Starlette( - debug=True, -) -app.mount(ServerMetadataPaths.OAUTH.value, mcp_auth.metadata_response()) -app.mount("/mcp", protected_app) diff --git a/samples/server/whoami.py b/samples/server/whoami.py new file mode 100644 index 0000000..5144052 --- /dev/null +++ b/samples/server/whoami.py @@ -0,0 +1,86 @@ +import os +from typing import Any +from mcp.server.fastmcp import FastMCP +import pydantic +import requests +from starlette.applications import Starlette +from starlette.routing import Mount +from starlette.middleware import Middleware + + +from mcpauth import MCPAuth +from mcpauth.config import AuthServerType +from mcpauth.exceptions import ( + MCPAuthTokenVerificationException, + MCPAuthTokenVerificationExceptionCode, +) +from mcpauth.types import AuthInfo +from mcpauth.utils import fetch_server_config + +mcp = FastMCP("WhoAmI") +issuer_placeholder = "https://replace-with-your-issuer-url.com" +auth_issuer = os.getenv("MCP_AUTH_ISSUER", issuer_placeholder) + +if auth_issuer == issuer_placeholder: + raise ValueError( + f"MCP_AUTH_ISSUER environment variable is not set. Please set it to your authorization server's issuer URL." + ) + +auth_server_config = fetch_server_config(auth_issuer, AuthServerType.OIDC) +mcp_auth = MCPAuth(server=auth_server_config) + + +@mcp.tool() +def whoami() -> dict[str, Any]: + """A tool that returns the current user's information.""" + return ( + mcp_auth.auth_info.claims + if mcp_auth.auth_info + else {"error": "Not authenticated"} + ) + + +def verify_access_token(token: str) -> AuthInfo: + endpoint = auth_server_config.metadata.userinfo_endpoint + if not endpoint: + raise ValueError( + "Userinfo endpoint is not configured in the auth server metadata." + ) + + try: + response = requests.get( + endpoint, + headers={"Authorization": f"Bearer {token}"}, + ) + response.raise_for_status() + json = response.json() + return AuthInfo( + token=token, + subject=json.get("sub"), + issuer=auth_issuer, + claims=json, + ) + except pydantic.ValidationError as e: + raise MCPAuthTokenVerificationException( + MCPAuthTokenVerificationExceptionCode.INVALID_TOKEN, + cause=e, + ) + except Exception as e: + raise MCPAuthTokenVerificationException( + MCPAuthTokenVerificationExceptionCode.TOKEN_VERIFICATION_FAILED, + cause=e, + ) + + +app = Starlette( + routes=[ + mcp_auth.metadata_route(), + Mount( + "/", + app=mcp.sse_app(), + middleware=[ + Middleware(mcp_auth.bearer_auth_middleware(verify_access_token)) + ], + ), + ] +) diff --git a/tests/__init__test.py b/tests/__init__test.py index 9aff35a..d73d113 100644 --- a/tests/__init__test.py +++ b/tests/__init__test.py @@ -1,7 +1,9 @@ +from contextvars import ContextVar import pytest from unittest.mock import patch, MagicMock from mcpauth import MCPAuth, MCPAuthAuthServerException, AuthServerExceptionCode from mcpauth.config import AuthServerConfig, AuthServerType, AuthorizationServerMetadata +from mcpauth.middleware.create_bearer_auth import BearerAuthConfig class TestMCPAuth: @@ -66,27 +68,58 @@ def test_init_with_warnings(self, mock_warning: MagicMock): assert mock_warning.called -class TestOAuthMetadataResponse: - def test_metadata_response(self): - # Setup - server_config = AuthServerConfig( - type=AuthServerType.OAUTH, - metadata=AuthorizationServerMetadata( - issuer="https://example.com", - authorization_endpoint="https://example.com/oauth/authorize", - token_endpoint="https://example.com/oauth/token", - response_types_supported=["code"], - grant_types_supported=["authorization_code"], - code_challenge_methods_supported=["S256"], - ), +@pytest.mark.asyncio +class TestOAuthMetadataEndpointAndRoute: + server_config = AuthServerConfig( + type=AuthServerType.OAUTH, + metadata=AuthorizationServerMetadata( + issuer="https://example.com", + authorization_endpoint="https://example.com/oauth/authorize", + token_endpoint="https://example.com/oauth/token", + response_types_supported=["code"], + grant_types_supported=["authorization_code"], + code_challenge_methods_supported=["S256"], + ), + ) + + async def test_metadata_endpoint(self): + auth = MCPAuth(server=self.server_config) + + options_request = MagicMock() + options_request.method = "OPTIONS" + options_response = await auth.metadata_endpoint()(options_request) + assert options_response.status_code == 204 + assert options_response.headers["Access-Control-Allow-Origin"] == "*" + assert ( + options_response.headers["Access-Control-Allow-Methods"] == "GET, OPTIONS" ) - auth = MCPAuth(server=server_config) - # Exercise - response = auth.metadata_response() + request = MagicMock() + request.method = "GET" + response = await auth.metadata_endpoint()(request) - # Verify assert response.status_code == 200 + assert response.body == self.server_config.metadata.model_dump_json( + exclude_none=True + ).encode("utf-8") + assert response.headers["Access-Control-Allow-Origin"] == "*" + assert response.headers["Access-Control-Allow-Methods"] == "GET, OPTIONS" + + async def test_metadata_route(self): + auth = MCPAuth(server=self.server_config) + route = auth.metadata_route() + + assert route.path == "/.well-known/oauth-authorization-server" + assert route.methods == {"GET", "HEAD", "OPTIONS"} + + # Mock a request to the route + request = MagicMock() + request.method = "GET" + response = await route.endpoint(request) + assert response.status_code == 200 + assert response.body == self.server_config.metadata.model_dump_json( + exclude_none=True + ).encode("utf-8") assert response.headers["Access-Control-Allow-Origin"] == "*" assert response.headers["Access-Control-Allow-Methods"] == "GET, OPTIONS" @@ -151,7 +184,9 @@ def test_bearer_auth_middleware_custom_verify(self): mock_create_bearer_auth.assert_called_once() args, kwargs = mock_create_bearer_auth.call_args assert args[0] == custom_verify - assert kwargs == {} + assert isinstance(kwargs, dict) + assert isinstance(kwargs.get("config"), BearerAuthConfig) # type: ignore + assert isinstance(kwargs.get("context_var"), ContextVar) # type: ignore def test_bearer_auth_middleware_jwt_without_jwks_uri(self): # Setup diff --git a/tests/exceptions_test.py b/tests/exceptions_test.py index 412ad5a..aa25169 100644 --- a/tests/exceptions_test.py +++ b/tests/exceptions_test.py @@ -6,8 +6,8 @@ MCPAuthBearerAuthException, MCPAuthBearerAuthExceptionDetails, MCPAuthException, - MCPAuthJwtVerificationException, - MCPAuthJwtVerificationExceptionCode, + MCPAuthTokenVerificationException, + MCPAuthTokenVerificationExceptionCode, ) @@ -94,21 +94,23 @@ def test_error_uri_and_missing_scopes_in_json(self): assert result["missing_scopes"] == ["scope1", "scope2"] -class TestMCPAuthJwtVerificationException: +class TestMCPAuthTokenVerificationException: def test_message_based_on_code(self): - mcp_exception = MCPAuthJwtVerificationException( - MCPAuthJwtVerificationExceptionCode.INVALID_JWT + mcp_exception = MCPAuthTokenVerificationException( + MCPAuthTokenVerificationExceptionCode.INVALID_TOKEN ) - assert mcp_exception.message == "The provided JWT is invalid or malformed." + assert mcp_exception.message == "The provided token is invalid or malformed." assert mcp_exception.to_json() == { - "error": "invalid_jwt", - "error_description": "The provided JWT is invalid or malformed.", + "error": "invalid_token", + "error_description": "The provided token is invalid or malformed.", } def test_default_message_for_unknown_code(self): - mcp_exception = MCPAuthJwtVerificationException(WrongExceptionCode.unknown) # type: ignore - assert mcp_exception.message == "An exception occurred while verifying the JWT." + mcp_exception = MCPAuthTokenVerificationException(WrongExceptionCode.unknown) # type: ignore + assert ( + mcp_exception.message == "An exception occurred while verifying the token." + ) assert mcp_exception.to_json() == { "error": "unknown_code", - "error_description": "An exception occurred while verifying the JWT.", + "error_description": "An exception occurred while verifying the token.", } diff --git a/tests/middleware/create_bearer_auth_test.py b/tests/middleware/create_bearer_auth_test.py index 063f889..2b054fb 100644 --- a/tests/middleware/create_bearer_auth_test.py +++ b/tests/middleware/create_bearer_auth_test.py @@ -1,3 +1,4 @@ +from contextvars import ContextVar import json import pytest from unittest.mock import MagicMock, AsyncMock @@ -5,7 +6,6 @@ from starlette.responses import Response, JSONResponse from starlette.middleware.base import BaseHTTPMiddleware from mcpauth.types import AuthInfo, VerifyAccessTokenFunction -from datetime import timedelta, datetime from mcpauth.middleware.create_bearer_auth import ( create_bearer_auth, @@ -14,10 +14,10 @@ ) from mcpauth.exceptions import ( AuthServerExceptionCode, - MCPAuthJwtVerificationException, + MCPAuthTokenVerificationException, MCPAuthAuthServerException, MCPAuthConfigException, - MCPAuthJwtVerificationExceptionCode, + MCPAuthTokenVerificationExceptionCode, ) @@ -26,6 +26,7 @@ def test_should_return_middleware_class(self): middleware = create_bearer_auth( lambda _: None, # type: ignore BearerAuthConfig(issuer="https://example.com"), + ContextVar("auth_info", default=None), ) assert callable(middleware) @@ -36,6 +37,7 @@ def test_should_throw_error_if_verify_access_token_is_not_a_function(self): create_bearer_auth( "not a function", # type: ignore BearerAuthConfig(issuer="https://example.com"), + ContextVar("auth_info", default=None), ) def test_should_throw_error_if_issuer_is_not_a_valid_url(self): @@ -43,13 +45,18 @@ def test_should_throw_error_if_issuer_is_not_a_valid_url(self): create_bearer_auth( lambda _: None, # type: ignore BearerAuthConfig(issuer="not a valid url"), + ContextVar("auth_info", default=None), ) @pytest.mark.asyncio class TestHandleBearerAuthMiddleware: @pytest.fixture - def auth_config(self): + def auth_info_context(self): + return ContextVar("auth_info", default=None) + + @pytest.fixture + def auth_config(self, auth_info_context: ContextVar[AuthInfo | None]): issuer = "https://example.com" required_scopes = ["read", "write"] audience = "test-audience" @@ -62,12 +69,11 @@ def verify_access_token(token: str) -> AuthInfo: scopes=["read", "write"], token=token, audience=audience, - expires_at=int((datetime.now() + timedelta(hours=1)).timestamp()), subject="subject-id", claims={"sub": "subject-id", "aud": audience, "iss": issuer}, ) - raise MCPAuthJwtVerificationException( - MCPAuthJwtVerificationExceptionCode.INVALID_JWT + raise MCPAuthTokenVerificationException( + MCPAuthTokenVerificationExceptionCode.INVALID_TOKEN ) return ( @@ -77,13 +83,19 @@ def verify_access_token(token: str) -> AuthInfo: required_scopes=required_scopes, audience=audience, ), + auth_info_context, ) @pytest.fixture def middleware( - self, auth_config: tuple[VerifyAccessTokenFunction, BearerAuthConfig] + self, + auth_config: tuple[ + VerifyAccessTokenFunction, BearerAuthConfig, ContextVar[AuthInfo | None] + ], ): - MiddlewareClass = create_bearer_auth(auth_config[0], auth_config[1]) + MiddlewareClass = create_bearer_auth( + auth_config[0], auth_config[1], auth_config[2] + ) return MiddlewareClass(app=MagicMock()) async def test_should_respond_with_error_if_request_does_not_have_bearer_token( @@ -174,18 +186,22 @@ async def test_should_respond_with_error_if_bearer_token_is_malformed( async def test_should_respond_with_error_if_bearer_token_is_not_valid( self, - auth_config: tuple[VerifyAccessTokenFunction, BearerAuthConfig], + auth_config: tuple[ + VerifyAccessTokenFunction, BearerAuthConfig, ContextVar[AuthInfo | None] + ], ): mock_verify = MagicMock( - side_effect=MCPAuthJwtVerificationException( - MCPAuthJwtVerificationExceptionCode.INVALID_JWT + side_effect=MCPAuthTokenVerificationException( + MCPAuthTokenVerificationExceptionCode.INVALID_TOKEN ) ) - MiddlewareClass = create_bearer_auth(mock_verify, auth_config[1]) + MiddlewareClass = create_bearer_auth( + mock_verify, auth_config[1], auth_config[2] + ) middleware = MiddlewareClass(app=MagicMock()) - mock_verify.side_effect = MCPAuthJwtVerificationException( - MCPAuthJwtVerificationExceptionCode.INVALID_JWT + mock_verify.side_effect = MCPAuthTokenVerificationException( + MCPAuthTokenVerificationExceptionCode.INVALID_TOKEN ) request = Request( @@ -203,14 +219,16 @@ async def test_should_respond_with_error_if_bearer_token_is_not_valid( assert isinstance(response, JSONResponse) and isinstance(response.body, bytes) response_data = json.loads(response.body.decode("utf-8")) assert response_data == { - "error": "invalid_jwt", - "error_description": "The provided JWT is invalid or malformed.", + "error": "invalid_token", + "error_description": "The provided token is invalid or malformed.", } mock_verify.assert_called_once_with("invalid-token") async def test_should_respond_with_error_if_issuer_does_not_match( self, - auth_config: tuple[VerifyAccessTokenFunction, BearerAuthConfig], + auth_config: tuple[ + VerifyAccessTokenFunction, BearerAuthConfig, ContextVar[AuthInfo | None] + ], ): mock_verify = MagicMock() mock_verify.return_value = AuthInfo( @@ -219,7 +237,6 @@ async def test_should_respond_with_error_if_issuer_does_not_match( scopes=["read", "write"], token="valid-token", audience=auth_config[1].audience, - expires_at=int((datetime.now() + timedelta(hours=1)).timestamp()), subject="subject-id", claims={ "sub": "subject-id", @@ -228,7 +245,9 @@ async def test_should_respond_with_error_if_issuer_does_not_match( }, ) - MiddlewareClass = create_bearer_auth(mock_verify, auth_config[1]) + MiddlewareClass = create_bearer_auth( + mock_verify, auth_config[1], auth_config[2] + ) middleware = MiddlewareClass(app=MagicMock()) request = Request( @@ -253,7 +272,9 @@ async def test_should_respond_with_error_if_issuer_does_not_match( async def test_should_respond_with_error_if_audience_does_not_match( self, - auth_config: tuple[VerifyAccessTokenFunction, BearerAuthConfig], + auth_config: tuple[ + VerifyAccessTokenFunction, BearerAuthConfig, ContextVar[AuthInfo | None] + ], ): mock_verify = MagicMock() mock_verify.return_value = AuthInfo( @@ -262,7 +283,6 @@ async def test_should_respond_with_error_if_audience_does_not_match( scopes=["read", "write"], token="valid-token", audience="wrong-audience", - expires_at=int((datetime.now() + timedelta(hours=1)).timestamp()), subject="subject-id", claims={ "sub": "subject-id", @@ -271,7 +291,9 @@ async def test_should_respond_with_error_if_audience_does_not_match( }, ) - MiddlewareClass = create_bearer_auth(mock_verify, auth_config[1]) + MiddlewareClass = create_bearer_auth( + mock_verify, auth_config[1], auth_config[2] + ) middleware = MiddlewareClass(app=MagicMock()) request = Request( @@ -296,7 +318,9 @@ async def test_should_respond_with_error_if_audience_does_not_match( async def test_should_respond_with_error_if_audience_does_not_match_array_case( self, - auth_config: tuple[VerifyAccessTokenFunction, BearerAuthConfig], + auth_config: tuple[ + VerifyAccessTokenFunction, BearerAuthConfig, ContextVar[AuthInfo | None] + ], ): mock_verify = MagicMock() mock_verify.return_value = AuthInfo( @@ -305,7 +329,6 @@ async def test_should_respond_with_error_if_audience_does_not_match_array_case( scopes=["read", "write"], token="valid-token", audience=["wrong-audience"], - expires_at=int((datetime.now() + timedelta(hours=1)).timestamp()), subject="subject-id", claims={ "sub": "subject-id", @@ -314,7 +337,9 @@ async def test_should_respond_with_error_if_audience_does_not_match_array_case( }, ) - MiddlewareClass = create_bearer_auth(mock_verify, auth_config[1]) + MiddlewareClass = create_bearer_auth( + mock_verify, auth_config[1], auth_config[2] + ) middleware = MiddlewareClass(app=MagicMock()) request = Request( @@ -339,7 +364,9 @@ async def test_should_respond_with_error_if_audience_does_not_match_array_case( async def test_should_respond_with_error_if_required_scopes_are_not_present( self, - auth_config: tuple[VerifyAccessTokenFunction, BearerAuthConfig], + auth_config: tuple[ + VerifyAccessTokenFunction, BearerAuthConfig, ContextVar[AuthInfo | None] + ], ): mock_verify = MagicMock() mock_verify.return_value = AuthInfo( @@ -348,7 +375,6 @@ async def test_should_respond_with_error_if_required_scopes_are_not_present( scopes=["read"], # Missing "write" scope token="valid-token", audience=auth_config[1].audience, - expires_at=int((datetime.now() + timedelta(hours=1)).timestamp()), subject="subject-id", claims={ "sub": "subject-id", @@ -357,7 +383,9 @@ async def test_should_respond_with_error_if_required_scopes_are_not_present( }, ) - MiddlewareClass = create_bearer_auth(mock_verify, auth_config[1]) + MiddlewareClass = create_bearer_auth( + mock_verify, auth_config[1], auth_config[2] + ) middleware = MiddlewareClass(app=MagicMock()) request = Request( @@ -393,8 +421,6 @@ async def test_should_call_next_if_token_is_valid_and_has_correct_audience_and_s } ) - # Create a mock for the next_call - # Create a mock for the next_call with AsyncMock next_call = AsyncMock() next_call.return_value = Response(status_code=200) @@ -405,7 +431,9 @@ async def test_should_call_next_if_token_is_valid_and_has_correct_audience_and_s assert response.status_code == 200 async def test_should_override_existing_auth_property_on_request( - self, middleware: BaseHTTPMiddleware + self, + middleware: BaseHTTPMiddleware, + auth_info_context: ContextVar[AuthInfo | None], ): # Create request with existing auth attribute request = Request( @@ -417,11 +445,15 @@ async def test_should_override_existing_auth_property_on_request( } ) - # Set pre-existing auth property - setattr( - request.state, - "auth", - {"client_id": "old-client-id", "scopes": ["old-scope"]}, + auth_info_context.set( + AuthInfo( + token="old-valid-token", + subject="old-subject-id", + issuer="https://old-issuer.com", + client_id="old-client-id", + scopes=["old-scope"], + claims={}, + ) ) # Create mock for next_call @@ -429,14 +461,15 @@ async def test_should_override_existing_auth_property_on_request( next_call.return_value = Response(status_code=200) response = await middleware.dispatch(request, next_call) + current_auth_info = auth_info_context.get() - # Check that auth was overridden with new values - assert hasattr(request.state, "auth") - assert request.state.auth.issuer == "https://example.com" - assert request.state.auth.client_id == "client-id" - assert request.state.auth.scopes == ["read", "write"] - assert request.state.auth.token == "valid-token" - assert request.state.auth.audience == "test-audience" + assert current_auth_info is not None + assert current_auth_info.issuer == "https://example.com" + assert current_auth_info.client_id == "client-id" + assert current_auth_info.scopes == ["read", "write"] + assert current_auth_info.token == "valid-token" + assert current_auth_info.audience == "test-audience" + assert current_auth_info.subject == "subject-id" next_call.assert_called_once() assert response.status_code == 200 @@ -456,7 +489,9 @@ async def test_should_handle_mcp_auth_server_error_and_config_error(self): show_error_details=True, ) - MiddlewareClass = create_bearer_auth(mock_verify, config) + MiddlewareClass = create_bearer_auth( + mock_verify, config, ContextVar("auth_info", default=None) + ) middleware = MiddlewareClass(app=MagicMock()) request = Request( @@ -493,6 +528,7 @@ async def test_should_handle_mcp_auth_server_error_and_config_error(self): BearerAuthConfig( issuer="https://example.com", required_scopes=[], audience=None ), + ContextVar("auth_info", default=None), ) config_error_middleware = config_error_middleware_class(app=MagicMock()) @@ -530,6 +566,7 @@ async def test_should_throw_for_unexpected_errors(self): BearerAuthConfig( issuer="https://example.com", required_scopes=[], audience=None ), + ContextVar("auth_info", default=None), ) middleware = middleware_class(app=MagicMock()) @@ -557,7 +594,6 @@ async def test_should_show_error_details_for_bearer_auth_error(self): scopes=required_scopes, token="valid-token", audience=audience, - expires_at=int((datetime.now() + timedelta(hours=1)).timestamp()), subject="subject-id", claims={"sub": "subject-id", "aud": audience, "iss": issuer + "1"}, ) @@ -570,6 +606,7 @@ async def test_should_show_error_details_for_bearer_auth_error(self): audience=audience, show_error_details=True, ), + ContextVar("auth_info", default=None), ) middleware = middleware_class(app=MagicMock()) diff --git a/tests/utils/create_verify_jwt_test.py b/tests/utils/create_verify_jwt_test.py index d561c05..9072960 100644 --- a/tests/utils/create_verify_jwt_test.py +++ b/tests/utils/create_verify_jwt_test.py @@ -8,8 +8,8 @@ from mcpauth.exceptions import ( - MCPAuthJwtVerificationException, - MCPAuthJwtVerificationExceptionCode, + MCPAuthTokenVerificationException, + MCPAuthTokenVerificationExceptionCode, ) _secret_key = b"super-secret-key-for-testing" @@ -52,10 +52,12 @@ def test_should_throw_error_if_signature_verification_fails(self): ) # Verify that the correct exception is raised - with pytest.raises(MCPAuthJwtVerificationException) as exc_info: + with pytest.raises(MCPAuthTokenVerificationException) as exc_info: verify_jwt(jwt_token) - assert exc_info.value.code == MCPAuthJwtVerificationExceptionCode.INVALID_JWT + assert ( + exc_info.value.code == MCPAuthTokenVerificationExceptionCode.INVALID_TOKEN + ) assert isinstance(exc_info.value.cause, jwt.InvalidSignatureError) def test_should_throw_error_if_jwt_payload_missing_iss(self): @@ -69,10 +71,11 @@ def test_should_throw_error_if_jwt_payload_missing_iss(self): ) for token in [jwt_missing_iss, jwt_invalid_iss_type, jwt_empty_iss]: - with pytest.raises(MCPAuthJwtVerificationException) as exc_info: + with pytest.raises(MCPAuthTokenVerificationException) as exc_info: verify_jwt(token) assert ( - exc_info.value.code == MCPAuthJwtVerificationExceptionCode.INVALID_JWT + exc_info.value.code + == MCPAuthTokenVerificationExceptionCode.INVALID_TOKEN ) def test_should_throw_error_if_jwt_payload_missing_client_id(self): @@ -92,10 +95,11 @@ def test_should_throw_error_if_jwt_payload_missing_client_id(self): jwt_invalid_client_id_type, jwt_empty_client_id, ]: - with pytest.raises(MCPAuthJwtVerificationException) as exc_info: + with pytest.raises(MCPAuthTokenVerificationException) as exc_info: verify_jwt(token) assert ( - exc_info.value.code == MCPAuthJwtVerificationExceptionCode.INVALID_JWT + exc_info.value.code + == MCPAuthTokenVerificationExceptionCode.INVALID_TOKEN ) def test_should_throw_error_if_jwt_payload_missing_sub(self): @@ -111,10 +115,11 @@ def test_should_throw_error_if_jwt_payload_missing_sub(self): ) for token in [jwt_missing_sub, jwt_invalid_sub_type, jwt_empty_sub]: - with pytest.raises(MCPAuthJwtVerificationException) as exc_info: + with pytest.raises(MCPAuthTokenVerificationException) as exc_info: verify_jwt(token) assert ( - exc_info.value.code == MCPAuthJwtVerificationExceptionCode.INVALID_JWT + exc_info.value.code + == MCPAuthTokenVerificationExceptionCode.INVALID_TOKEN ) @@ -143,7 +148,6 @@ def test_should_return_verified_jwt_payload_with_string_scope(self): assert result.scopes == ["read", "write"] assert "exp" in result.claims assert "iat" in result.claims - assert result.expires_at is not None def test_should_return_verified_jwt_payload_with_array_scope(self): # Create JWT with array scope diff --git a/uv.lock b/uv.lock index 8e3aff5..2d97b48 100644 --- a/uv.lock +++ b/uv.lock @@ -1,11 +1,6 @@ version = 1 revision = 2 -requires-python = ">=3.9" -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version >= '3.10' and python_full_version < '3.12'", - "python_full_version < '3.10'", -] +requires-python = ">=3.10" [[package]] name = "annotated-types" @@ -62,10 +57,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190, upload_time = "2025-01-29T05:37:22.106Z" }, { url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926, upload_time = "2025-01-29T04:18:58.564Z" }, { url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613, upload_time = "2025-01-29T04:19:27.63Z" }, - { url = "https://files.pythonhosted.org/packages/d3/b6/ae7507470a4830dbbfe875c701e84a4a5fb9183d1497834871a715716a92/black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0", size = 1628593, upload_time = "2025-01-29T05:37:23.672Z" }, - { url = "https://files.pythonhosted.org/packages/24/c1/ae36fa59a59f9363017ed397750a0cd79a470490860bc7713967d89cdd31/black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f", size = 1460000, upload_time = "2025-01-29T05:37:25.829Z" }, - { url = "https://files.pythonhosted.org/packages/ac/b6/98f832e7a6c49aa3a464760c67c7856363aa644f2f3c74cf7d624168607e/black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e", size = 1765963, upload_time = "2025-01-29T04:18:38.116Z" }, - { url = "https://files.pythonhosted.org/packages/ce/e9/2cb0a017eb7024f70e0d2e9bdb8c5a5b078c5740c7f8816065d06f04c557/black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355", size = 1419419, upload_time = "2025-01-29T04:18:30.191Z" }, { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646, upload_time = "2025-01-29T04:15:38.082Z" }, ] @@ -133,18 +124,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload_time = "2024-09-04T20:44:41.616Z" }, { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload_time = "2024-09-04T20:44:43.733Z" }, { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload_time = "2024-09-04T20:44:45.309Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ea/8bb50596b8ffbc49ddd7a1ad305035daa770202a6b782fc164647c2673ad/cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", size = 182220, upload_time = "2024-09-04T20:45:01.577Z" }, - { url = "https://files.pythonhosted.org/packages/ae/11/e77c8cd24f58285a82c23af484cf5b124a376b32644e445960d1a4654c3a/cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", size = 178605, upload_time = "2024-09-04T20:45:03.837Z" }, - { url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910, upload_time = "2024-09-04T20:45:05.315Z" }, - { url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200, upload_time = "2024-09-04T20:45:06.903Z" }, - { url = "https://files.pythonhosted.org/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565, upload_time = "2024-09-04T20:45:08.975Z" }, - { url = "https://files.pythonhosted.org/packages/74/06/90b8a44abf3556599cdec107f7290277ae8901a58f75e6fe8f970cd72418/cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", size = 435635, upload_time = "2024-09-04T20:45:10.64Z" }, - { url = "https://files.pythonhosted.org/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218, upload_time = "2024-09-04T20:45:12.366Z" }, - { url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486, upload_time = "2024-09-04T20:45:13.935Z" }, - { url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911, upload_time = "2024-09-04T20:45:15.696Z" }, - { url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632, upload_time = "2024-09-04T20:45:17.284Z" }, - { url = "https://files.pythonhosted.org/packages/cb/b5/fd9f8b5a84010ca169ee49f4e4ad6f8c05f4e3545b72ee041dbbcb159882/cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", size = 171820, upload_time = "2024-09-04T20:45:18.762Z" }, - { url = "https://files.pythonhosted.org/packages/8c/52/b08750ce0bce45c143e1b5d7357ee8c55341b52bdef4b0f081af1eb248c2/cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", size = 181290, upload_time = "2024-09-04T20:45:20.226Z" }, ] [[package]] @@ -205,19 +184,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732, upload_time = "2024-12-24T18:11:22.774Z" }, { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391, upload_time = "2024-12-24T18:11:24.139Z" }, { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702, upload_time = "2024-12-24T18:11:26.535Z" }, - { url = "https://files.pythonhosted.org/packages/7f/c0/b913f8f02836ed9ab32ea643c6fe4d3325c3d8627cf6e78098671cafff86/charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41", size = 197867, upload_time = "2024-12-24T18:12:10.438Z" }, - { url = "https://files.pythonhosted.org/packages/0f/6c/2bee440303d705b6fb1e2ec789543edec83d32d258299b16eed28aad48e0/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f", size = 141385, upload_time = "2024-12-24T18:12:11.847Z" }, - { url = "https://files.pythonhosted.org/packages/3d/04/cb42585f07f6f9fd3219ffb6f37d5a39b4fd2db2355b23683060029c35f7/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2", size = 151367, upload_time = "2024-12-24T18:12:13.177Z" }, - { url = "https://files.pythonhosted.org/packages/54/54/2412a5b093acb17f0222de007cc129ec0e0df198b5ad2ce5699355269dfe/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770", size = 143928, upload_time = "2024-12-24T18:12:14.497Z" }, - { url = "https://files.pythonhosted.org/packages/5a/6d/e2773862b043dcf8a221342954f375392bb2ce6487bcd9f2c1b34e1d6781/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4", size = 146203, upload_time = "2024-12-24T18:12:15.731Z" }, - { url = "https://files.pythonhosted.org/packages/b9/f8/ca440ef60d8f8916022859885f231abb07ada3c347c03d63f283bec32ef5/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537", size = 148082, upload_time = "2024-12-24T18:12:18.641Z" }, - { url = "https://files.pythonhosted.org/packages/04/d2/42fd330901aaa4b805a1097856c2edf5095e260a597f65def493f4b8c833/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496", size = 142053, upload_time = "2024-12-24T18:12:20.036Z" }, - { url = "https://files.pythonhosted.org/packages/9e/af/3a97a4fa3c53586f1910dadfc916e9c4f35eeada36de4108f5096cb7215f/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78", size = 150625, upload_time = "2024-12-24T18:12:22.804Z" }, - { url = "https://files.pythonhosted.org/packages/26/ae/23d6041322a3556e4da139663d02fb1b3c59a23ab2e2b56432bd2ad63ded/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7", size = 153549, upload_time = "2024-12-24T18:12:24.163Z" }, - { url = "https://files.pythonhosted.org/packages/94/22/b8f2081c6a77cb20d97e57e0b385b481887aa08019d2459dc2858ed64871/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6", size = 150945, upload_time = "2024-12-24T18:12:25.415Z" }, - { url = "https://files.pythonhosted.org/packages/c7/0b/c5ec5092747f801b8b093cdf5610e732b809d6cb11f4c51e35fc28d1d389/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294", size = 146595, upload_time = "2024-12-24T18:12:28.03Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5a/0b59704c38470df6768aa154cc87b1ac7c9bb687990a1559dc8765e8627e/charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5", size = 95453, upload_time = "2024-12-24T18:12:29.569Z" }, - { url = "https://files.pythonhosted.org/packages/85/2d/a9790237cb4d01a6d57afadc8573c8b73c609ade20b80f4cda30802009ee/charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765", size = 102811, upload_time = "2024-12-24T18:12:30.83Z" }, { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767, upload_time = "2024-12-24T18:12:32.852Z" }, ] @@ -298,16 +264,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/e9/8fb8e0ff6bef5e170ee19d59ca694f9001b2ec085dc99b4f65c128bb3f9a/coverage-7.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883", size = 255116, upload_time = "2025-03-30T20:36:18.033Z" }, { url = "https://files.pythonhosted.org/packages/56/b0/d968ecdbe6fe0a863de7169bbe9e8a476868959f3af24981f6a10d2b6924/coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada", size = 214909, upload_time = "2025-03-30T20:36:19.644Z" }, { url = "https://files.pythonhosted.org/packages/87/e9/d6b7ef9fecf42dfb418d93544af47c940aa83056c49e6021a564aafbc91f/coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257", size = 216068, upload_time = "2025-03-30T20:36:21.282Z" }, - { url = "https://files.pythonhosted.org/packages/60/0c/5da94be095239814bf2730a28cffbc48d6df4304e044f80d39e1ae581997/coverage-7.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa260de59dfb143af06dcf30c2be0b200bed2a73737a8a59248fcb9fa601ef0f", size = 211377, upload_time = "2025-03-30T20:36:23.298Z" }, - { url = "https://files.pythonhosted.org/packages/d5/cb/b9e93ebf193a0bb89dbcd4f73d7b0e6ecb7c1b6c016671950e25f041835e/coverage-7.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:96121edfa4c2dfdda409877ea8608dd01de816a4dc4a0523356067b305e4e17a", size = 211803, upload_time = "2025-03-30T20:36:25.74Z" }, - { url = "https://files.pythonhosted.org/packages/78/1a/cdbfe9e1bb14d3afcaf6bb6e1b9ba76c72666e329cd06865bbd241efd652/coverage-7.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8af63b9afa1031c0ef05b217faa598f3069148eeee6bb24b79da9012423b82", size = 240561, upload_time = "2025-03-30T20:36:27.548Z" }, - { url = "https://files.pythonhosted.org/packages/59/04/57f1223f26ac018d7ce791bfa65b0c29282de3e041c1cd3ed430cfeac5a5/coverage-7.8.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89b1f4af0d4afe495cd4787a68e00f30f1d15939f550e869de90a86efa7e0814", size = 238488, upload_time = "2025-03-30T20:36:29.175Z" }, - { url = "https://files.pythonhosted.org/packages/b7/b1/0f25516ae2a35e265868670384feebe64e7857d9cffeeb3887b0197e2ba2/coverage-7.8.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94ec0be97723ae72d63d3aa41961a0b9a6f5a53ff599813c324548d18e3b9e8c", size = 239589, upload_time = "2025-03-30T20:36:30.876Z" }, - { url = "https://files.pythonhosted.org/packages/e0/a4/99d88baac0d1d5a46ceef2dd687aac08fffa8795e4c3e71b6f6c78e14482/coverage-7.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8a1d96e780bdb2d0cbb297325711701f7c0b6f89199a57f2049e90064c29f6bd", size = 239366, upload_time = "2025-03-30T20:36:32.563Z" }, - { url = "https://files.pythonhosted.org/packages/ea/9e/1db89e135feb827a868ed15f8fc857160757f9cab140ffee21342c783ceb/coverage-7.8.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f1d8a2a57b47142b10374902777e798784abf400a004b14f1b0b9eaf1e528ba4", size = 237591, upload_time = "2025-03-30T20:36:34.721Z" }, - { url = "https://files.pythonhosted.org/packages/1b/6d/ac4d6fdfd0e201bc82d1b08adfacb1e34b40d21a22cdd62cfaf3c1828566/coverage-7.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cf60dd2696b457b710dd40bf17ad269d5f5457b96442f7f85722bdb16fa6c899", size = 238572, upload_time = "2025-03-30T20:36:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/25/5e/917cbe617c230f7f1745b6a13e780a3a1cd1cf328dbcd0fd8d7ec52858cd/coverage-7.8.0-cp39-cp39-win32.whl", hash = "sha256:be945402e03de47ba1872cd5236395e0f4ad635526185a930735f66710e1bd3f", size = 213966, upload_time = "2025-03-30T20:36:38.551Z" }, - { url = "https://files.pythonhosted.org/packages/bd/93/72b434fe550135869f9ea88dd36068af19afce666db576e059e75177e813/coverage-7.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:90e7fbc6216ecaffa5a880cdc9c77b7418c1dcb166166b78dbc630d07f278cc3", size = 214852, upload_time = "2025-03-30T20:36:40.209Z" }, { url = "https://files.pythonhosted.org/packages/c4/f1/1da77bb4c920aa30e82fa9b6ea065da3467977c2e5e032e38e66f1c57ffd/coverage-7.8.0-pp39.pp310.pp311-none-any.whl", hash = "sha256:b8194fb8e50d556d5849753de991d390c5a1edeeba50f68e3a9253fbd8bf8ccd", size = 203443, upload_time = "2025-03-30T20:36:41.959Z" }, { url = "https://files.pythonhosted.org/packages/59/f1/4da7717f0063a222db253e7121bd6a56f6fb1ba439dcc36659088793347c/coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7", size = 203435, upload_time = "2025-03-30T20:36:43.61Z" }, ] @@ -380,6 +336,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload_time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload_time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload_time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload_time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload_time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624, upload_time = "2023-12-22T08:01:21.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819, upload_time = "2023-12-22T08:01:19.89Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -398,6 +391,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload_time = "2025-03-19T20:10:01.071Z" }, ] +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload_time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload_time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "mcp" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/ae/588691c45b38f4fbac07fa3d6d50cea44cc6b35d16ddfdf26e17a0467ab2/mcp-1.7.1.tar.gz", hash = "sha256:eb4f1f53bd717f75dda8a1416e00804b831a8f3c331e23447a03b78f04b43a6e", size = 230903, upload_time = "2025-05-02T17:01:56.403Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/79/fe0e20c3358997a80911af51bad927b5ea2f343ef95ab092b19c9cc48b59/mcp-1.7.1-py3-none-any.whl", hash = "sha256:f7e6108977db6d03418495426c7ace085ba2341b75197f8727f96f9cfd30057a", size = 100365, upload_time = "2025-05-02T17:01:54.674Z" }, +] + +[package.optional-dependencies] +cli = [ + { name = "python-dotenv" }, + { name = "typer" }, +] + [[package]] name = "mcpauth" version = "0.0.0" @@ -412,6 +443,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "black" }, + { name = "mcp", extra = ["cli"] }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -430,6 +462,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "black", specifier = ">=24.8.0" }, + { name = "mcp", extras = ["cli"], specifier = ">=1.7.1" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-asyncio", specifier = ">=0.26.0" }, { name = "pytest-cov", specifier = ">=6.1.1" }, @@ -437,6 +470,15 @@ dev = [ { name = "uvicorn", specifier = ">=0.34.2" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload_time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload_time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mypy-extensions" version = "1.1.0" @@ -573,19 +615,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/fd/24ea4302d7a527d672c5be06e17df16aabfb4e9fdc6e0b345c21580f3d2a/pydantic_core-2.33.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:401d7b76e1000d0dd5538e6381d28febdcacb097c8d340dde7d7fc6e13e9f95d", size = 1812963, upload_time = "2025-04-02T09:48:14.553Z" }, { url = "https://files.pythonhosted.org/packages/5f/95/4fbc2ecdeb5c1c53f1175a32d870250194eb2fdf6291b795ab08c8646d5d/pydantic_core-2.33.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aeb055a42d734c0255c9e489ac67e75397d59c6fbe60d155851e9782f276a9c", size = 1986896, upload_time = "2025-04-02T09:48:16.222Z" }, { url = "https://files.pythonhosted.org/packages/71/ae/fe31e7f4a62431222d8f65a3bd02e3fa7e6026d154a00818e6d30520ea77/pydantic_core-2.33.1-cp313-cp313t-win_amd64.whl", hash = "sha256:338ea9b73e6e109f15ab439e62cb3b78aa752c7fd9536794112e14bee02c8d18", size = 1931810, upload_time = "2025-04-02T09:48:17.97Z" }, - { url = "https://files.pythonhosted.org/packages/49/78/b86bad645cc3e8dfa6858c70ec38939bf350e54004837c48de09474b2b9e/pydantic_core-2.33.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:5ab77f45d33d264de66e1884fca158bc920cb5e27fd0764a72f72f5756ae8bdb", size = 2044282, upload_time = "2025-04-02T09:48:19.849Z" }, - { url = "https://files.pythonhosted.org/packages/3b/00/a02531331773b2bf08743d84c6b776bd6a449d23b3ae6b0e3229d568bac4/pydantic_core-2.33.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7aaba1b4b03aaea7bb59e1b5856d734be011d3e6d98f5bcaa98cb30f375f2ad", size = 1877598, upload_time = "2025-04-02T09:48:22.863Z" }, - { url = "https://files.pythonhosted.org/packages/a1/fa/32cc152b84a1f420f8a7d80161373e8d87d4ffa077e67d6c8aab3ce1a6ab/pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fb66263e9ba8fea2aa85e1e5578980d127fb37d7f2e292773e7bc3a38fb0c7b", size = 1911021, upload_time = "2025-04-02T09:48:24.592Z" }, - { url = "https://files.pythonhosted.org/packages/5e/87/ea553e0d98bce6c4876f8c50f65cb45597eff6e0aaa8b15813e9972bb19d/pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f2648b9262607a7fb41d782cc263b48032ff7a03a835581abbf7a3bec62bcf5", size = 1997276, upload_time = "2025-04-02T09:48:26.314Z" }, - { url = "https://files.pythonhosted.org/packages/f7/9b/60cb9f4b52158b3adac0066492bbadd0b8473f4f8da5bcc73972655b76ef/pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:723c5630c4259400818b4ad096735a829074601805d07f8cafc366d95786d331", size = 2141348, upload_time = "2025-04-02T09:48:28.298Z" }, - { url = "https://files.pythonhosted.org/packages/9b/38/374d254e270d4de0add68a8239f4ed0f444fdd7b766ea69244fb9491dccb/pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d100e3ae783d2167782391e0c1c7a20a31f55f8015f3293647544df3f9c67824", size = 2753708, upload_time = "2025-04-02T09:48:29.987Z" }, - { url = "https://files.pythonhosted.org/packages/05/a8/fd79111eb5ab9bc4ef98d8fb0b3a2ffdc80107b2c59859a741ab379c96f8/pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177d50460bc976a0369920b6c744d927b0ecb8606fb56858ff542560251b19e5", size = 2008699, upload_time = "2025-04-02T09:48:31.76Z" }, - { url = "https://files.pythonhosted.org/packages/35/31/2e06619868eb4c18642c5601db420599c1cf9cf50fe868c9ac09cd298e24/pydantic_core-2.33.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3edde68d1a1f9af1273b2fe798997b33f90308fb6d44d8550c89fc6a3647cf6", size = 2123426, upload_time = "2025-04-02T09:48:33.623Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d0/3531e8783a311802e3db7ee5a1a5ed79e5706e930b1b4e3109ce15eeb681/pydantic_core-2.33.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a62c3c3ef6a7e2c45f7853b10b5bc4ddefd6ee3cd31024754a1a5842da7d598d", size = 2087330, upload_time = "2025-04-02T09:48:35.387Z" }, - { url = "https://files.pythonhosted.org/packages/ac/32/5ff252ed73bacd7677a706ab17723e261a76793f98b305aa20cfc10bbd56/pydantic_core-2.33.1-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:c91dbb0ab683fa0cd64a6e81907c8ff41d6497c346890e26b23de7ee55353f96", size = 2258171, upload_time = "2025-04-02T09:48:37.559Z" }, - { url = "https://files.pythonhosted.org/packages/c9/f9/e96e00f92b8f5b3e2cddc80c5ee6cf038f8a0f238c44b67b01759943a7b4/pydantic_core-2.33.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9f466e8bf0a62dc43e068c12166281c2eca72121dd2adc1040f3aa1e21ef8599", size = 2258745, upload_time = "2025-04-02T09:48:39.413Z" }, - { url = "https://files.pythonhosted.org/packages/54/1e/51c86688e809d94797fdf0efc41514f001caec982a05f62d90c180a9639d/pydantic_core-2.33.1-cp39-cp39-win32.whl", hash = "sha256:ab0277cedb698749caada82e5d099dc9fed3f906a30d4c382d1a21725777a1e5", size = 1923626, upload_time = "2025-04-02T09:48:41.24Z" }, - { url = "https://files.pythonhosted.org/packages/57/18/c2da959fd8d019b70cadafdda2bf845378ada47973e0bad6cc84f56dbe6e/pydantic_core-2.33.1-cp39-cp39-win_amd64.whl", hash = "sha256:5773da0ee2d17136b1f1c6fbde543398d452a6ad2a7b54ea1033e2daa739b8d2", size = 1953703, upload_time = "2025-04-02T09:48:43.196Z" }, { url = "https://files.pythonhosted.org/packages/9c/c7/8b311d5adb0fe00a93ee9b4e92a02b0ec08510e9838885ef781ccbb20604/pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c834f54f8f4640fd7e4b193f80eb25a0602bba9e19b3cd2fc7ffe8199f5ae02", size = 2041659, upload_time = "2025-04-02T09:48:45.342Z" }, { url = "https://files.pythonhosted.org/packages/8a/d6/4f58d32066a9e26530daaf9adc6664b01875ae0691570094968aaa7b8fcc/pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:049e0de24cf23766f12cc5cc71d8abc07d4a9deb9061b334b62093dedc7cb068", size = 1873294, upload_time = "2025-04-02T09:48:47.548Z" }, { url = "https://files.pythonhosted.org/packages/f7/3f/53cc9c45d9229da427909c751f8ed2bf422414f7664ea4dde2d004f596ba/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a28239037b3d6f16916a4c831a5a0eadf856bdd6d2e92c10a0da3a59eadcf3e", size = 1903771, upload_time = "2025-04-02T09:48:49.468Z" }, @@ -604,15 +633,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/cd/c59707e35a47ba4cbbf153c3f7c56420c58653b5801b055dc52cccc8e2dc/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:52928d8c1b6bda03cc6d811e8923dffc87a2d3c8b3bfd2ce16471c7147a24850", size = 2250175, upload_time = "2025-04-02T09:49:15.597Z" }, { url = "https://files.pythonhosted.org/packages/84/32/e4325a6676b0bed32d5b084566ec86ed7fd1e9bcbfc49c578b1755bde920/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1b30d92c9412beb5ac6b10a3eb7ef92ccb14e3f2a8d7732e2d739f58b3aa7544", size = 2254674, upload_time = "2025-04-02T09:49:17.61Z" }, { url = "https://files.pythonhosted.org/packages/12/6f/5596dc418f2e292ffc661d21931ab34591952e2843e7168ea5a52591f6ff/pydantic_core-2.33.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f995719707e0e29f0f41a8aa3bcea6e761a36c9136104d3189eafb83f5cec5e5", size = 2080951, upload_time = "2025-04-02T09:49:19.559Z" }, - { url = "https://files.pythonhosted.org/packages/2d/a8/c2c8f29bd18f7ef52de32a6deb9e3ee87ba18b7b2122636aa9f4438cf627/pydantic_core-2.33.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7edbc454a29fc6aeae1e1eecba4f07b63b8d76e76a748532233c4c167b4cb9ea", size = 2041791, upload_time = "2025-04-02T09:49:21.617Z" }, - { url = "https://files.pythonhosted.org/packages/08/ad/328081b1c82543ae49d0650048305058583c51f1a9a56a0d6e87bb3a2443/pydantic_core-2.33.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad05b683963f69a1d5d2c2bdab1274a31221ca737dbbceaa32bcb67359453cdd", size = 1873579, upload_time = "2025-04-02T09:49:23.667Z" }, - { url = "https://files.pythonhosted.org/packages/6e/8a/bc65dbf7e501e88367cdab06a2c1340457c785f0c72288cae737fd80c0fa/pydantic_core-2.33.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df6a94bf9452c6da9b5d76ed229a5683d0306ccb91cca8e1eea883189780d568", size = 1904189, upload_time = "2025-04-02T09:49:25.821Z" }, - { url = "https://files.pythonhosted.org/packages/9a/db/30ca6aefda211fb01ef185ca73cb7a0c6e7fe952c524025c8782b5acd771/pydantic_core-2.33.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7965c13b3967909a09ecc91f21d09cfc4576bf78140b988904e94f130f188396", size = 2084446, upload_time = "2025-04-02T09:49:27.866Z" }, - { url = "https://files.pythonhosted.org/packages/f2/89/a12b55286e30c9f476eab7c53c9249ec76faf70430596496ab0309f28629/pydantic_core-2.33.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3f1fdb790440a34f6ecf7679e1863b825cb5ffde858a9197f851168ed08371e5", size = 2118215, upload_time = "2025-04-02T09:49:30.321Z" }, - { url = "https://files.pythonhosted.org/packages/8e/55/12721c4a8d7951584ad3d9848b44442559cf1876e0bb424148d1060636b3/pydantic_core-2.33.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:5277aec8d879f8d05168fdd17ae811dd313b8ff894aeeaf7cd34ad28b4d77e33", size = 2079963, upload_time = "2025-04-02T09:49:32.804Z" }, - { url = "https://files.pythonhosted.org/packages/bd/0c/3391bd5d6ff62ea998db94732528d9bc32c560b0ed861c39119759461946/pydantic_core-2.33.1-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8ab581d3530611897d863d1a649fb0644b860286b4718db919bfd51ece41f10b", size = 2249388, upload_time = "2025-04-02T09:49:34.906Z" }, - { url = "https://files.pythonhosted.org/packages/d3/5f/3e4feb042998d7886a9b523b372d83955cbc192a07013dcd24276db078ee/pydantic_core-2.33.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0483847fa9ad5e3412265c1bd72aad35235512d9ce9d27d81a56d935ef489672", size = 2255226, upload_time = "2025-04-02T09:49:37.412Z" }, - { url = "https://files.pythonhosted.org/packages/25/f2/1647933efaaad61846109a27619f3704929e758a09e6431b8f932a053d40/pydantic_core-2.33.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:de9e06abe3cc5ec6a2d5f75bc99b0bdca4f5c719a5b34026f8c57efbdecd2ee3", size = 2081073, upload_time = "2025-04-02T09:49:39.531Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234, upload_time = "2025-04-18T16:44:48.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356, upload_time = "2025-04-18T16:44:46.617Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload_time = "2025-01-06T17:26:30.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload_time = "2025-01-06T17:26:25.553Z" }, ] [[package]] @@ -652,7 +695,6 @@ version = "0.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8e/c4/453c52c659521066969523e87d85d54139bbd17b78f09532fb8eb8cdb58e/pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f", size = 54156, upload_time = "2025-03-25T06:22:28.883Z" } wheels = [ @@ -672,6 +714,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841, upload_time = "2025-04-05T14:07:49.641Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload_time = "2025-03-25T10:14:56.835Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload_time = "2025-03-25T10:14:55.034Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload_time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload_time = "2024-12-16T19:45:44.423Z" }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -714,15 +774,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload_time = "2024-08-06T20:32:56.985Z" }, { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload_time = "2024-08-06T20:33:03.001Z" }, { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload_time = "2024-08-06T20:33:04.33Z" }, - { url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777, upload_time = "2024-08-06T20:33:25.896Z" }, - { url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318, upload_time = "2024-08-06T20:33:27.212Z" }, - { url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891, upload_time = "2024-08-06T20:33:28.974Z" }, - { url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614, upload_time = "2024-08-06T20:33:34.157Z" }, - { url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360, upload_time = "2024-08-06T20:33:35.84Z" }, - { url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006, upload_time = "2024-08-06T20:33:37.501Z" }, - { url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577, upload_time = "2024-08-06T20:33:39.389Z" }, - { url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593, upload_time = "2024-08-06T20:33:46.63Z" }, - { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312, upload_time = "2024-08-06T20:33:49.073Z" }, ] [[package]] @@ -754,6 +805,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/fc/1d20b64fa90e81e4fa0a34c9b0240a6cfb1326b7e06d18a5432a9917c316/responses-0.25.7-py3-none-any.whl", hash = "sha256:92ca17416c90fe6b35921f52179bff29332076bb32694c0df02dcac2c6bc043c", size = 34732, upload_time = "2025-03-11T15:36:14.589Z" }, ] +[[package]] +name = "rich" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload_time = "2025-03-30T14:15:14.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload_time = "2025-03-30T14:15:12.283Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload_time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload_time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -763,13 +837,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload_time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "sse-starlette" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/35/7d8d94eb0474352d55f60f80ebc30f7e59441a29e18886a6425f0bccd0d3/sse_starlette-2.3.3.tar.gz", hash = "sha256:fdd47c254aad42907cfd5c5b83e2282be15be6c51197bf1a9b70b8e990522072", size = 17499, upload_time = "2025-04-23T19:28:25.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/20/52fdb5ebb158294b0adb5662235dd396fc7e47aa31c293978d8d8942095a/sse_starlette-2.3.3-py3-none-any.whl", hash = "sha256:8b0a0ced04a329ff7341b01007580dd8cf71331cc21c0ccea677d500618da1e0", size = 10235, upload_time = "2025-04-23T19:28:24.115Z" }, +] + [[package]] name = "starlette" version = "0.46.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload_time = "2025-04-13T13:56:17.942Z" } wheels = [ @@ -815,6 +901,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload_time = "2024-11-27T22:38:35.385Z" }, ] +[[package]] +name = "typer" +version = "0.15.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/1a/5f36851f439884bcfe8539f6a20ff7516e7b60f319bbaf69a90dc35cc2eb/typer-0.15.3.tar.gz", hash = "sha256:818873625d0569653438316567861899f7e9972f2e6e0c16dab608345ced713c", size = 101641, upload_time = "2025-04-28T21:40:59.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/20/9d953de6f4367163d23ec823200eb3ecb0050a2609691e512c8b95827a9b/typer-0.15.3-py3-none-any.whl", hash = "sha256:c86a65ad77ca531f03de08d1b9cb67cd09ad02ddddf4b34745b5008f43b239bd", size = 45253, upload_time = "2025-04-28T21:40:56.269Z" }, +] + [[package]] name = "typing-extensions" version = "4.13.2" From 5937b0931ade0873439585e826024724ac7c44e8 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Fri, 2 May 2025 20:41:52 -0700 Subject: [PATCH 2/2] refactor: update tests --- mcpauth/utils/_validate_server_config.py | 16 +-------- tests/__init__test.py | 40 ++++++++++----------- tests/middleware/create_bearer_auth_test.py | 34 +++++++++++------- tests/utils/create_verify_jwt_test.py | 20 +++++++++++ 4 files changed, 63 insertions(+), 47 deletions(-) diff --git a/mcpauth/utils/_validate_server_config.py b/mcpauth/utils/_validate_server_config.py index da96d42..f0f7f57 100644 --- a/mcpauth/utils/_validate_server_config.py +++ b/mcpauth/utils/_validate_server_config.py @@ -1,6 +1,6 @@ from enum import Enum from typing import Any, Dict, List, Optional -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel from ..config import AuthServerConfig @@ -99,22 +99,8 @@ def validate_server_config( errors: List[AuthServerConfigError] = [] warnings: List[AuthServerConfigWarning] = [] - metadata = config.metadata - # Validate metadata - try: - # Validation is already done by Pydantic when the object is created - # But we can add additional validation if needed - pass - except ValidationError as e: - errors.append( - _create_error(AuthServerConfigErrorCode.INVALID_SERVER_METADATA, e) - ) - return AuthServerConfigValidationResult( - is_valid=False, errors=errors, warnings=warnings - ) - # Check if 'code' is included in any of the supported response types has_code_response_type = any( "code" in response_type.split(" ") diff --git a/tests/__init__test.py b/tests/__init__test.py index d73d113..d94d40f 100644 --- a/tests/__init__test.py +++ b/tests/__init__test.py @@ -1,9 +1,8 @@ -from contextvars import ContextVar import pytest -from unittest.mock import patch, MagicMock +from unittest.mock import AsyncMock, patch, MagicMock from mcpauth import MCPAuth, MCPAuthAuthServerException, AuthServerExceptionCode from mcpauth.config import AuthServerConfig, AuthServerType, AuthorizationServerMetadata -from mcpauth.middleware.create_bearer_auth import BearerAuthConfig +from mcpauth.types import AuthInfo class TestMCPAuth: @@ -154,8 +153,8 @@ def test_bearer_auth_middleware_jwt_mode(self): "https://example.com/.well-known/jwks.json", leeway=60 ) - def test_bearer_auth_middleware_custom_verify(self): - # Setup + @pytest.mark.asyncio + async def test_bearer_auth_middleware_custom_verify(self): server_config = AuthServerConfig( type=AuthServerType.OAUTH, metadata=AuthorizationServerMetadata( @@ -169,24 +168,25 @@ def test_bearer_auth_middleware_custom_verify(self): ) auth = MCPAuth(server=server_config) + auth_info = AuthInfo( + token="valid_token", + issuer="https://example.com", + subject="1234567890", + scopes=["profile"], + claims={}, + ) custom_verify = MagicMock() + custom_verify.return_value = auth_info - # Exercise - with patch( - "mcpauth.middleware.create_bearer_auth.create_bearer_auth" - ) as mock_create_bearer_auth: - middleware_class = auth.bearer_auth_middleware( - custom_verify, required_scopes=["profile"] - ) + middleware_class = auth.bearer_auth_middleware( + custom_verify, required_scopes=["profile"] + ) - # Verify - assert middleware_class is not None - mock_create_bearer_auth.assert_called_once() - args, kwargs = mock_create_bearer_auth.call_args - assert args[0] == custom_verify - assert isinstance(kwargs, dict) - assert isinstance(kwargs.get("config"), BearerAuthConfig) # type: ignore - assert isinstance(kwargs.get("context_var"), ContextVar) # type: ignore + mock_request = MagicMock() + mock_request.headers = {"Authorization": "Bearer valid_token"} + middleware_instance = middleware_class(MagicMock()) + await middleware_instance.dispatch(mock_request, AsyncMock()) + assert auth.auth_info == auth_info def test_bearer_auth_middleware_jwt_without_jwks_uri(self): # Setup diff --git a/tests/middleware/create_bearer_auth_test.py b/tests/middleware/create_bearer_auth_test.py index 2b054fb..2bf37d4 100644 --- a/tests/middleware/create_bearer_auth_test.py +++ b/tests/middleware/create_bearer_auth_test.py @@ -22,41 +22,51 @@ class TestHandleBearerAuth: - def test_should_return_middleware_class(self): + @pytest.fixture + def auth_info(self): + return ContextVar("auth_info", default=None) + + def test_should_return_middleware_class( + self, auth_info: ContextVar[AuthInfo | None] + ): middleware = create_bearer_auth( lambda _: None, # type: ignore BearerAuthConfig(issuer="https://example.com"), - ContextVar("auth_info", default=None), + auth_info, ) assert callable(middleware) - def test_should_throw_error_if_verify_access_token_is_not_a_function(self): + def test_should_throw_error_if_verify_access_token_is_not_a_function( + self, auth_info: ContextVar[AuthInfo | None] + ): with pytest.raises( TypeError, match=r"`verify_access_token` must be a function" ): create_bearer_auth( "not a function", # type: ignore BearerAuthConfig(issuer="https://example.com"), - ContextVar("auth_info", default=None), + auth_info, ) - def test_should_throw_error_if_issuer_is_not_a_valid_url(self): + def test_should_throw_error_if_issuer_is_not_a_valid_url( + self, auth_info: ContextVar[AuthInfo | None] + ): with pytest.raises(TypeError, match=r"`issuer` must be a valid URL."): create_bearer_auth( lambda _: None, # type: ignore BearerAuthConfig(issuer="not a valid url"), - ContextVar("auth_info", default=None), + auth_info, ) @pytest.mark.asyncio class TestHandleBearerAuthMiddleware: @pytest.fixture - def auth_info_context(self): + def auth_info(self): return ContextVar("auth_info", default=None) @pytest.fixture - def auth_config(self, auth_info_context: ContextVar[AuthInfo | None]): + def auth_config(self, auth_info: ContextVar[AuthInfo | None]): issuer = "https://example.com" required_scopes = ["read", "write"] audience = "test-audience" @@ -83,7 +93,7 @@ def verify_access_token(token: str) -> AuthInfo: required_scopes=required_scopes, audience=audience, ), - auth_info_context, + auth_info, ) @pytest.fixture @@ -433,7 +443,7 @@ async def test_should_call_next_if_token_is_valid_and_has_correct_audience_and_s async def test_should_override_existing_auth_property_on_request( self, middleware: BaseHTTPMiddleware, - auth_info_context: ContextVar[AuthInfo | None], + auth_info: ContextVar[AuthInfo | None], ): # Create request with existing auth attribute request = Request( @@ -445,7 +455,7 @@ async def test_should_override_existing_auth_property_on_request( } ) - auth_info_context.set( + auth_info.set( AuthInfo( token="old-valid-token", subject="old-subject-id", @@ -461,7 +471,7 @@ async def test_should_override_existing_auth_property_on_request( next_call.return_value = Response(status_code=200) response = await middleware.dispatch(request, next_call) - current_auth_info = auth_info_context.get() + current_auth_info = auth_info.get() assert current_auth_info is not None assert current_auth_info.issuer == "https://example.com" diff --git a/tests/utils/create_verify_jwt_test.py b/tests/utils/create_verify_jwt_test.py index 9072960..a78f7c7 100644 --- a/tests/utils/create_verify_jwt_test.py +++ b/tests/utils/create_verify_jwt_test.py @@ -1,3 +1,4 @@ +from unittest.mock import MagicMock import pytest import time import jwt @@ -122,6 +123,25 @@ def test_should_throw_error_if_jwt_payload_missing_sub(self): == MCPAuthTokenVerificationExceptionCode.INVALID_TOKEN ) + def test_should_throw_error_if_unknown_exception_occurs(self): + # Mock get_signing_key_from_jwt to raise an unexpected exception + mock_jwk_client = MagicMock() + mock_jwk_client.get_signing_key_from_jwt.side_effect = Exception( + "Unexpected error" + ) + verify_jwt = create_verify_jwt(mock_jwk_client, algorithms=[_algorithm]) + jwt_token = create_jwt( + {"iss": "https://logto.io/", "client_id": "client12345", "sub": "user12345"} + ) + + # Verify that the correct exception is raised + with pytest.raises(MCPAuthTokenVerificationException) as exc_info: + verify_jwt(jwt_token) + assert ( + exc_info.value.code + == MCPAuthTokenVerificationExceptionCode.TOKEN_VERIFICATION_FAILED + ) + class TestCreateVerifyJwtNormalBehavior: def test_should_return_verified_jwt_payload_with_string_scope(self):