diff --git a/examples/clients/simple-auth-client/pyproject.toml b/examples/clients/simple-auth-client/pyproject.toml index 5ae7c6b9d..bbe7f9b8f 100644 --- a/examples/clients/simple-auth-client/pyproject.toml +++ b/examples/clients/simple-auth-client/pyproject.toml @@ -14,10 +14,7 @@ classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", ] -dependencies = [ - "click>=8.0.0", - "mcp>=1.0.0", -] +dependencies = ["click>=8.0.0", "mcp"] [project.scripts] mcp-simple-auth-client = "mcp_simple_auth_client.main:cli" @@ -44,9 +41,3 @@ target-version = "py310" [tool.uv] dev-dependencies = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"] - -[tool.uv.sources] -mcp = { path = "../../../" } - -[[tool.uv.index]] -url = "https://pypi.org/simple" diff --git a/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py b/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py index aa813b542..daa1e47ec 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py +++ b/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py @@ -73,6 +73,8 @@ async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: async def register_client(self, client_info: OAuthClientInformationFull): """Register a new OAuth client.""" + if not client_info.client_id: + raise ValueError("No client_id provided") self.clients[client_info.client_id] = client_info async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: @@ -209,6 +211,8 @@ async def exchange_authorization_code( """Exchange authorization code for tokens.""" if authorization_code.code not in self.auth_codes: raise ValueError("Invalid authorization code") + if not client.client_id: + raise ValueError("No client_id provided") # Generate MCP access token mcp_token = f"mcp_{secrets.token_hex(32)}" diff --git a/pyproject.toml b/pyproject.toml index 474c58f6e..721540d40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "uvicorn>=0.23.1; sys_platform != 'emscripten'", "jsonschema>=4.20.0", "pywin32>=310; sys_platform == 'win32'", + "pyjwt[crypto]>=2.10.1", ] [project.optional-dependencies] @@ -107,7 +108,7 @@ extend-exclude = ["README.md"] "tests/server/fastmcp/test_func_metadata.py" = ["E501"] [tool.uv.workspace] -members = ["examples/servers/*", "examples/snippets"] +members = ["examples/clients/*", "examples/servers/*", "examples/snippets"] [tool.uv.sources] mcp = { workspace = true } diff --git a/src/mcp/client/auth.py b/src/mcp/client/auth.py index 775fb0f6c..3ac659bac 100644 --- a/src/mcp/client/auth.py +++ b/src/mcp/client/auth.py @@ -13,11 +13,13 @@ import time from collections.abc import AsyncGenerator, Awaitable, Callable from dataclasses import dataclass, field -from typing import Protocol +from typing import Any, Protocol from urllib.parse import urlencode, urljoin, urlparse +from uuid import uuid4 import anyio import httpx +import jwt from pydantic import BaseModel, Field, ValidationError from mcp.client.streamable_http import MCP_PROTOCOL_VERSION @@ -61,6 +63,24 @@ def generate(cls) -> "PKCEParameters": return cls(code_verifier=code_verifier, code_challenge=code_challenge) +class JWTParameters(BaseModel): + """JWT parameters.""" + + assertion: str | None = Field( + default=None, + description="JWT assertion for JWT authentication. " + "Will be used instead of generating a new assertion if provided.", + ) + + issuer: str | None = Field(default=None, description="Issuer for JWT assertions.") + subject: str | None = Field(default=None, description="Subject identifier for JWT assertions.") + audience: str | None = Field(default=None, description="Audience for JWT assertions.") + claims: dict[str, Any] | None = Field(default=None, description="Additional claims for JWT assertions.") + jwt_signing_algorithm: str | None = Field(default="RS256", description="Algorithm for signing JWT assertions.") + jwt_signing_key: str | None = Field(default=None, description="Private key for JWT signing.") + jwt_lifetime_seconds: int = Field(default=300, description="Lifetime of generated JWT in seconds.") + + class TokenStorage(Protocol): """Protocol for token storage implementations.""" @@ -88,9 +108,10 @@ class OAuthContext: server_url: str client_metadata: OAuthClientMetadata storage: TokenStorage - redirect_handler: Callable[[str], Awaitable[None]] - callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] + redirect_handler: Callable[[str], Awaitable[None]] | None + callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None timeout: float = 300.0 + jwt_parameters: JWTParameters | None = None # Discovered metadata protected_resource_metadata: ProtectedResourceMetadata | None = None @@ -189,9 +210,10 @@ def __init__( server_url: str, client_metadata: OAuthClientMetadata, storage: TokenStorage, - redirect_handler: Callable[[str], Awaitable[None]], - callback_handler: Callable[[], Awaitable[tuple[str, str | None]]], + redirect_handler: Callable[[str], Awaitable[None]] | None = None, + callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None, timeout: float = 300.0, + jwt_parameters: JWTParameters | None = None, ): """Initialize OAuth2 authentication.""" self.context = OAuthContext( @@ -201,6 +223,7 @@ def __init__( redirect_handler=redirect_handler, callback_handler=callback_handler, timeout=timeout, + jwt_parameters=jwt_parameters, ) self._initialized = False @@ -309,8 +332,28 @@ async def _handle_registration_response(self, response: httpx.Response) -> None: except ValidationError as e: raise OAuthRegistrationError(f"Invalid registration response: {e}") - async def _perform_authorization(self) -> tuple[str, str]: + async def _perform_authorization(self) -> httpx.Request: + """Perform the authorization flow.""" + if "client_credentials" in self.context.client_metadata.grant_types: + token_request = await self._exchange_token_client_credentials() + return token_request + elif "urn:ietf:params:oauth:grant-type:jwt-bearer" in self.context.client_metadata.grant_types: + token_request = await self._exchange_token_jwt_bearer() + return token_request + else: + auth_code, code_verifier = await self._perform_authorization_code_grant() + token_request = await self._exchange_token_authorization_code(auth_code, code_verifier) + return token_request + + async def _perform_authorization_code_grant(self) -> tuple[str, str]: """Perform the authorization redirect and get auth code.""" + if self.context.client_metadata.redirect_uris is None: + raise OAuthFlowError("No redirect URIs provided for authorization code grant") + if not self.context.redirect_handler: + raise OAuthFlowError("No redirect handler provided for authorization code grant") + if not self.context.callback_handler: + raise OAuthFlowError("No callback handler provided for authorization code grant") + if self.context.oauth_metadata and self.context.oauth_metadata.authorization_endpoint: auth_endpoint = str(self.context.oauth_metadata.authorization_endpoint) else: @@ -355,17 +398,22 @@ async def _perform_authorization(self) -> tuple[str, str]: # Return auth code and code verifier for token exchange return auth_code, pkce_params.code_verifier - async def _exchange_token(self, auth_code: str, code_verifier: str) -> httpx.Request: - """Build token exchange request.""" - if not self.context.client_info: - raise OAuthFlowError("Missing client info") - + def _get_token_endpoint(self) -> str: if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint: token_url = str(self.context.oauth_metadata.token_endpoint) else: auth_base_url = self.context.get_authorization_base_url(self.context.server_url) token_url = urljoin(auth_base_url, "/token") + return token_url + + async def _exchange_token_authorization_code(self, auth_code: str, code_verifier: str) -> httpx.Request: + """Build token exchange request for authorization_code flow.""" + if self.context.client_metadata.redirect_uris is None: + raise OAuthFlowError("No redirect URIs provided for authorization code grant") + if not self.context.client_info: + raise OAuthFlowError("Missing client info") + token_url = self._get_token_endpoint() token_data = { "grant_type": "authorization_code", "code": auth_code, @@ -385,10 +433,137 @@ async def _exchange_token(self, auth_code: str, code_verifier: str) -> httpx.Req "POST", token_url, data=token_data, headers={"Content-Type": "application/x-www-form-urlencoded"} ) + async def _exchange_token_client_credentials(self) -> httpx.Request: + """Build token exchange request for client_credentials flow.""" + if not self.context.client_info: + raise OAuthFlowError("Missing client info") + + token_url = self._get_token_endpoint() + token_data = { + "grant_type": "client_credentials", + } + + headers = {"Content-Type": "application/x-www-form-urlencoded"} + + # Only include resource param if conditions are met + if self.context.should_include_resource_param(self.context.protocol_version): + token_data["resource"] = self.context.get_resource_url() # RFC 8707 + + if self.context.client_metadata.scope: + token_data["scope"] = self.context.client_metadata.scope + + if self.context.client_metadata.token_endpoint_auth_method == "client_secret_post": + # Include in request body + if self.context.client_info.client_id: + token_data["client_id"] = self.context.client_info.client_id + if self.context.client_info.client_secret: + token_data["client_secret"] = self.context.client_info.client_secret + elif self.context.client_metadata.token_endpoint_auth_method == "client_secret_basic": + # Include as Basic auth header + if not self.context.client_info.client_id: + raise OAuthTokenError("Missing client_id in Basic auth flow") + if not self.context.client_info.client_secret: + raise OAuthTokenError("Missing client_secret in Basic auth flow") + raw_auth = f"{self.context.client_info.client_id}:{self.context.client_info.client_secret}" + headers["Authorization"] = f"Basic {base64.b64encode(raw_auth.encode()).decode()}" + elif self.context.client_metadata.token_endpoint_auth_method == "private_key_jwt": + # Use JWT assertion for client authentication + if not self.context.jwt_parameters: + raise OAuthTokenError("Missing JWT parameters for private_key_jwt flow") + + if self.context.jwt_parameters.assertion is not None: + # Prebuilt JWT (e.g. acquired out-of-band) + assertion = self.context.jwt_parameters.assertion + else: + if not self.context.jwt_parameters.jwt_signing_key: + raise OAuthTokenError("Missing JWT signing key for private_key_jwt flow") + if not self.context.jwt_parameters.jwt_signing_algorithm: + raise OAuthTokenError("Missing JWT signing algorithm for private_key_jwt flow") + + now = int(time.time()) + claims = { + "iss": self.context.jwt_parameters.issuer, + "sub": self.context.jwt_parameters.subject, + "aud": self.context.jwt_parameters.audience if self.context.jwt_parameters.audience else token_url, + "exp": now + self.context.jwt_parameters.jwt_lifetime_seconds, + "iat": now, + "jti": str(uuid4()), + } + claims.update(self.context.jwt_parameters.claims or {}) + + assertion = jwt.encode( + claims, + self.context.jwt_parameters.jwt_signing_key, + algorithm=self.context.jwt_parameters.jwt_signing_algorithm or "RS256", + ) + + # When using private_key_jwt, in a client_credentials flow, we use RFC 7523 Section 2.2 + token_data["client_assertion"] = assertion + token_data["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + # We need to set the audience to the token endpoint, the audience is difference from the one in claims + # it represents the resource server that will validate the token + token_data["audience"] = self.context.get_resource_url() + + return httpx.Request("POST", token_url, data=token_data, headers=headers) + + async def _exchange_token_jwt_bearer(self) -> httpx.Request: + """Build token exchange request for JWT bearer grant.""" + if not self.context.client_info: + raise OAuthFlowError("Missing client info") + if not self.context.jwt_parameters: + raise OAuthFlowError("Missing JWT parameters") + + token_url = self._get_token_endpoint() + + if self.context.jwt_parameters.assertion is not None: + # Prebuilt JWT (e.g. acquired out-of-band) + assertion = self.context.jwt_parameters.assertion + else: + if not self.context.jwt_parameters.jwt_signing_key: + raise OAuthFlowError("Missing signing key for JWT bearer grant") + if not self.context.jwt_parameters.issuer: + raise OAuthFlowError("Missing issuer for JWT bearer grant") + if not self.context.jwt_parameters.subject: + raise OAuthFlowError("Missing subject for JWT bearer grant") + + now = int(time.time()) + claims = { + "iss": self.context.jwt_parameters.issuer, + "sub": self.context.jwt_parameters.subject, + "aud": token_url, + "exp": now + self.context.jwt_parameters.jwt_lifetime_seconds, + "iat": now, + "jti": str(uuid4()), + } + claims.update(self.context.jwt_parameters.claims or {}) + + assertion = jwt.encode( + claims, + self.context.jwt_parameters.jwt_signing_key, + algorithm=self.context.jwt_parameters.jwt_signing_algorithm or "RS256", + ) + + token_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + } + + if self.context.should_include_resource_param(self.context.protocol_version): + token_data["resource"] = self.context.get_resource_url() + + if self.context.client_metadata.scope: + token_data["scope"] = self.context.client_metadata.scope + + return httpx.Request( + "POST", token_url, data=token_data, headers={"Content-Type": "application/x-www-form-urlencoded"} + ) + async def _handle_token_response(self, response: httpx.Response) -> None: """Handle token exchange response.""" if response.status_code != 200: - raise OAuthTokenError(f"Token exchange failed: {response.status_code}") + body = await response.aread() + body = body.decode("utf-8") + raise OAuthTokenError(f"Token exchange failed ({response.status_code}): {body}") try: content = await response.aread() @@ -535,12 +710,8 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. registration_response = yield registration_request await self._handle_registration_response(registration_response) - # Step 4: Perform authorization - auth_code, code_verifier = await self._perform_authorization() - - # Step 5: Exchange authorization code for tokens - token_request = await self._exchange_token(auth_code, code_verifier) - token_response = yield token_request + # Step 4: Perform authorization and complete token exchange + token_response = yield await self._perform_authorization() await self._handle_token_response(token_response) except Exception: logger.exception("OAuth flow error") diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index 33878ee15..aa5fcfd94 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -41,13 +41,17 @@ class OAuthClientMetadata(BaseModel): for the full specification. """ - redirect_uris: list[AnyUrl] = Field(..., min_length=1) - # token_endpoint_auth_method: this implementation only supports none & - # client_secret_post; - # ie: we do not support client_secret_basic - token_endpoint_auth_method: Literal["none", "client_secret_post"] = "client_secret_post" - # grant_types: this implementation only supports authorization_code & refresh_token - grant_types: list[Literal["authorization_code", "refresh_token"]] = [ + redirect_uris: list[AnyUrl] | None = Field(..., min_length=1) + # supported auth methods for the token endpoint + token_endpoint_auth_method: Literal["none", "client_secret_basic", "client_secret_post", "private_key_jwt"] = ( + "client_secret_post" + ) + # supported grant_types of this implementation + grant_types: list[ + Literal[ + "authorization_code", "client_credentials", "refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer" + ] + ] = [ "authorization_code", "refresh_token", ] @@ -81,10 +85,10 @@ def validate_scope(self, requested_scope: str | None) -> list[str] | None: def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl: if redirect_uri is not None: # Validate redirect_uri against client's registered redirect URIs - if redirect_uri not in self.redirect_uris: + if self.redirect_uris is None or redirect_uri not in self.redirect_uris: raise InvalidRedirectUriError(f"Redirect URI '{redirect_uri}' not registered for client") return redirect_uri - elif len(self.redirect_uris) == 1: + elif self.redirect_uris is not None and len(self.redirect_uris) == 1: return self.redirect_uris[0] else: raise InvalidRedirectUriError("redirect_uri must be specified when client has multiple registered URIs") @@ -96,7 +100,7 @@ class OAuthClientInformationFull(OAuthClientMetadata): (client information plus metadata). """ - client_id: str + client_id: str | None = None client_secret: str | None = None client_id_issued_at: int | None = None client_secret_expires_at: int | None = None @@ -117,7 +121,7 @@ class OAuthMetadata(BaseModel): response_modes_supported: list[Literal["query", "fragment", "form_post"]] | None = None grant_types_supported: list[str] | None = None token_endpoint_auth_methods_supported: list[str] | None = None - token_endpoint_auth_signing_alg_values_supported: None = None + token_endpoint_auth_signing_alg_values_supported: list[str] | None = None service_documentation: AnyHttpUrl | None = None ui_locales_supported: list[str] | None = None op_policy_uri: AnyHttpUrl | None = None diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index bb962bfc1..84ed0090d 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -2,15 +2,19 @@ Tests for refactored OAuth client authentication implementation. """ +import base64 import time +import urllib +import urllib.parse from unittest import mock import httpx +import jwt import pytest from inline_snapshot import Is, snapshot from pydantic import AnyHttpUrl, AnyUrl -from mcp.client.auth import OAuthClientProvider, PKCEParameters +from mcp.client.auth import JWTParameters, OAuthClientProvider, PKCEParameters from mcp.shared.auth import ( OAuthClientInformationFull, OAuthClientMetadata, @@ -262,7 +266,7 @@ async def test_oauth_discovery_fallback_order(self, oauth_provider): ] @pytest.mark.anyio - async def test_oauth_discovery_fallback_conditions(self, oauth_provider): + async def test_oauth_discovery_fallback_conditions(self, oauth_provider: OAuthClientProvider): """Test the conditions during which an AS metadata discovery fallback will be attempted.""" # Ensure no tokens are stored oauth_provider.context.current_tokens = None @@ -346,7 +350,9 @@ async def test_oauth_discovery_fallback_conditions(self, oauth_provider): ) # Mock the authorization process to minimize unnecessary state in this test - oauth_provider._perform_authorization = mock.AsyncMock(return_value=("test_auth_code", "test_code_verifier")) + oauth_provider._perform_authorization_code_grant = mock.AsyncMock( + return_value=("test_auth_code", "test_code_verifier") + ) # Next request should fall back to legacy behavior and auth with the RS (mocked /authorize, next is /token) token_request = await auth_flow.asend(oauth_metadata_response_3) @@ -405,7 +411,7 @@ async def test_register_client_skip_if_registered(self, oauth_provider, mock_sto assert request is None @pytest.mark.anyio - async def test_token_exchange_request(self, oauth_provider): + async def test_token_exchange_request_authorization_code(self, oauth_provider): """Test token exchange request building.""" # Set up required context oauth_provider.context.client_info = OAuthClientInformationFull( @@ -414,7 +420,7 @@ async def test_token_exchange_request(self, oauth_provider): redirect_uris=[AnyUrl("http://localhost:3030/callback")], ) - request = await oauth_provider._exchange_token("test_auth_code", "test_verifier") + request = await oauth_provider._exchange_token_authorization_code("test_auth_code", "test_verifier") assert request.method == "POST" assert str(request.url) == "https://api.example.com/token" @@ -428,6 +434,150 @@ async def test_token_exchange_request(self, oauth_provider): assert "client_id=test_client" in content assert "client_secret=test_secret" in content + @pytest.mark.anyio + async def test_token_exchange_request_client_credentials_basic(self, oauth_provider: OAuthClientProvider): + """Test token exchange request building.""" + # Set up required context + oauth_provider.context.client_info = oauth_provider.context.client_metadata = OAuthClientInformationFull( + grant_types=["client_credentials"], + token_endpoint_auth_method="client_secret_basic", + client_id="test_client", + client_secret="test_secret", + redirect_uris=None, + scope="read write", + ) + oauth_provider.context.protocol_version = "2025-06-18" + + request = await oauth_provider._exchange_token_client_credentials() + + assert request.method == "POST" + assert str(request.url) == "https://api.example.com/token" + assert request.headers["Content-Type"] == "application/x-www-form-urlencoded" + + # Check form data + content = urllib.parse.unquote_plus(request.content.decode()) + assert "grant_type=client_credentials" in content + assert "scope=read write" in content + assert "resource=https://api.example.com/v1/mcp" in content + assert "client_id=test_client" not in content + assert "client_secret=test_secret" not in content + + # Check auth header + assert "Authorization" in request.headers + assert request.headers["Authorization"].startswith("Basic ") + assert base64.b64decode(request.headers["Authorization"].split(" ")[1]).decode() == "test_client:test_secret" + + @pytest.mark.anyio + async def test_token_exchange_request_client_credentials_post(self, oauth_provider: OAuthClientProvider): + """Test token exchange request building.""" + # Set up required context + oauth_provider.context.client_info = oauth_provider.context.client_metadata = OAuthClientInformationFull( + grant_types=["client_credentials"], + token_endpoint_auth_method="client_secret_post", + client_id="test_client", + client_secret="test_secret", + redirect_uris=None, + scope="read write", + ) + oauth_provider.context.protocol_version = "2025-06-18" + + request = await oauth_provider._exchange_token_client_credentials() + + assert request.method == "POST" + assert str(request.url) == "https://api.example.com/token" + assert request.headers["Content-Type"] == "application/x-www-form-urlencoded" + + # Check form data + content = urllib.parse.unquote_plus(request.content.decode()) + assert "grant_type=client_credentials" in content + assert "scope=read write" in content + assert "resource=https://api.example.com/v1/mcp" in content + assert "client_id=test_client" in content + assert "client_secret=test_secret" in content + + @pytest.mark.anyio + async def test_token_exchange_request_jwt_predefined(self, oauth_provider: OAuthClientProvider): + """Test token exchange request building with a predefined JWT assertion.""" + # Set up required context + oauth_provider.context.client_info = oauth_provider.context.client_metadata = OAuthClientInformationFull( + grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"], + token_endpoint_auth_method="private_key_jwt", + redirect_uris=None, + scope="read write", + ) + oauth_provider.context.protocol_version = "2025-06-18" + oauth_provider.context.jwt_parameters = JWTParameters( + # https://www.jwt.io + assertion="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30" + ) + + request = await oauth_provider._exchange_token_jwt_bearer() + + assert request.method == "POST" + assert str(request.url) == "https://api.example.com/token" + assert request.headers["Content-Type"] == "application/x-www-form-urlencoded" + + # Check form data + content = urllib.parse.unquote_plus(request.content.decode()) + assert "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" in content + assert "scope=read write" in content + assert "resource=https://api.example.com/v1/mcp" in content + assert ( + "assertion=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30" + in content + ) + + @pytest.mark.anyio + async def test_token_exchange_request_jwt(self, oauth_provider: OAuthClientProvider): + """Test token exchange request building wiith a generated JWT assertion.""" + # Set up required context + oauth_provider.context.client_info = oauth_provider.context.client_metadata = OAuthClientInformationFull( + grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"], + token_endpoint_auth_method="private_key_jwt", + redirect_uris=None, + scope="read write", + ) + oauth_provider.context.protocol_version = "2025-06-18" + oauth_provider.context.jwt_parameters = JWTParameters( + issuer="foo", + subject="1234567890", + claims={ + "name": "John Doe", + "admin": True, + "iat": 1516239022, + }, + jwt_signing_algorithm="HS256", + jwt_signing_key="a-string-secret-at-least-256-bits-long", + jwt_lifetime_seconds=300, + ) + + request = await oauth_provider._exchange_token_jwt_bearer() + + assert request.method == "POST" + assert str(request.url) == "https://api.example.com/token" + assert request.headers["Content-Type"] == "application/x-www-form-urlencoded" + + # Check form data + content = urllib.parse.unquote_plus(request.content.decode()).split("&") + assert "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" in content + assert "scope=read write" in content + assert "resource=https://api.example.com/v1/mcp" in content + + # Check assertion + assertion = next(param for param in content if param.startswith("assertion="))[len("assertion=") :] + claims = jwt.decode( + assertion, + key="a-string-secret-at-least-256-bits-long", + algorithms=["HS256"], + audience="https://api.example.com/token", + subject="1234567890", + issuer="foo", + verify=True, + ) + assert claims["name"] == "John Doe" + assert claims["admin"] + assert claims["iat"] == 1516239022 + @pytest.mark.anyio async def test_refresh_token_request(self, oauth_provider, valid_tokens): """Test refresh token request building.""" @@ -468,7 +618,7 @@ async def test_resource_param_included_with_recent_protocol_version(self, oauth_ ) # Test in token exchange - request = await oauth_provider._exchange_token("test_code", "test_verifier") + request = await oauth_provider._exchange_token_authorization_code("test_code", "test_verifier") content = request.content.decode() assert "resource=" in content # Check URL-encoded resource parameter @@ -499,7 +649,7 @@ async def test_resource_param_excluded_with_old_protocol_version(self, oauth_pro ) # Test in token exchange - request = await oauth_provider._exchange_token("test_code", "test_verifier") + request = await oauth_provider._exchange_token_authorization_code("test_code", "test_verifier") content = request.content.decode() assert "resource=" not in content @@ -529,7 +679,7 @@ async def test_resource_param_included_with_protected_resource_metadata(self, oa ) # Test in token exchange - request = await oauth_provider._exchange_token("test_code", "test_verifier") + request = await oauth_provider._exchange_token_authorization_code("test_code", "test_verifier") content = request.content.decode() assert "resource=" in content @@ -600,7 +750,7 @@ async def test_auth_flow_with_valid_tokens(self, oauth_provider, mock_storage, v pass # Expected @pytest.mark.anyio - async def test_auth_flow_with_no_tokens(self, oauth_provider, mock_storage): + async def test_auth_flow_with_no_tokens(self, oauth_provider: OAuthClientProvider, mock_storage): """Test auth flow when no tokens are available, triggering the full OAuth flow.""" # Ensure no tokens are stored oauth_provider.context.current_tokens = None @@ -669,7 +819,9 @@ async def test_auth_flow_with_no_tokens(self, oauth_provider, mock_storage): ) # Mock the authorization process - oauth_provider._perform_authorization = mock.AsyncMock(return_value=("test_auth_code", "test_code_verifier")) + oauth_provider._perform_authorization_code_grant = mock.AsyncMock( + return_value=("test_auth_code", "test_code_verifier") + ) # Next request should be to exchange token token_request = await auth_flow.asend(registration_response) diff --git a/tests/server/fastmcp/auth/test_auth_integration.py b/tests/server/fastmcp/auth/test_auth_integration.py index e4a8f3f4c..3905b6e7c 100644 --- a/tests/server/fastmcp/auth/test_auth_integration.py +++ b/tests/server/fastmcp/auth/test_auth_integration.py @@ -50,6 +50,7 @@ async def register_client(self, client_info: OAuthClientInformationFull): async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: # toy authorize implementation which just immediately generates an authorization # code and completes the redirect + assert client.client_id is not None code = AuthorizationCode( code=f"code_{int(time.time())}", client_id=client.client_id, @@ -78,6 +79,7 @@ async def exchange_authorization_code( refresh_token = f"refresh_{secrets.token_hex(32)}" # Store the tokens + assert client.client_id is not None self.tokens[access_token] = AccessToken( token=access_token, client_id=client.client_id, @@ -139,6 +141,7 @@ async def exchange_refresh_token( new_refresh_token = f"refresh_{secrets.token_hex(32)}" # Store the new tokens + assert client.client_id is not None self.tokens[new_access_token] = AccessToken( token=new_access_token, client_id=client.client_id, diff --git a/uv.lock b/uv.lock index 7a34275ce..820071125 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,8 @@ requires-python = ">=3.10" members = [ "mcp", "mcp-simple-auth", + "mcp-simple-auth-client", + "mcp-simple-chatbot", "mcp-simple-prompt", "mcp-simple-resource", "mcp-simple-streamablehttp", @@ -289,6 +291,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "45.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/1e/49527ac611af559665f71cbb8f92b332b5ec9c6fbc4e88b0f8e92f5e85df/cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a", size = 744903, upload-time = "2025-07-02T13:06:25.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/fb/09e28bc0c46d2c547085e60897fea96310574c70fb21cd58a730a45f3403/cryptography-45.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:101ee65078f6dd3e5a028d4f19c07ffa4dd22cce6a20eaa160f8b5219911e7d8", size = 7043092, upload-time = "2025-07-02T13:05:01.514Z" }, + { url = "https://files.pythonhosted.org/packages/b1/05/2194432935e29b91fb649f6149c1a4f9e6d3d9fc880919f4ad1bcc22641e/cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d", size = 4205926, upload-time = "2025-07-02T13:05:04.741Z" }, + { url = "https://files.pythonhosted.org/packages/07/8b/9ef5da82350175e32de245646b1884fc01124f53eb31164c77f95a08d682/cryptography-45.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e74d30ec9c7cb2f404af331d5b4099a9b322a8a6b25c4632755c8757345baac5", size = 4429235, upload-time = "2025-07-02T13:05:07.084Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e1/c809f398adde1994ee53438912192d92a1d0fc0f2d7582659d9ef4c28b0c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3af26738f2db354aafe492fb3869e955b12b2ef2e16908c8b9cb928128d42c57", size = 4209785, upload-time = "2025-07-02T13:05:09.321Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8b/07eb6bd5acff58406c5e806eff34a124936f41a4fb52909ffa4d00815f8c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e6c00130ed423201c5bc5544c23359141660b07999ad82e34e7bb8f882bb78e0", size = 3893050, upload-time = "2025-07-02T13:05:11.069Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ef/3333295ed58d900a13c92806b67e62f27876845a9a908c939f040887cca9/cryptography-45.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:dd420e577921c8c2d31289536c386aaa30140b473835e97f83bc71ea9d2baf2d", size = 4457379, upload-time = "2025-07-02T13:05:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9d/44080674dee514dbb82b21d6fa5d1055368f208304e2ab1828d85c9de8f4/cryptography-45.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d05a38884db2ba215218745f0781775806bde4f32e07b135348355fe8e4991d9", size = 4209355, upload-time = "2025-07-02T13:05:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d8/0749f7d39f53f8258e5c18a93131919ac465ee1f9dccaf1b3f420235e0b5/cryptography-45.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad0caded895a00261a5b4aa9af828baede54638754b51955a0ac75576b831b27", size = 4456087, upload-time = "2025-07-02T13:05:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/09/d7/92acac187387bf08902b0bf0699816f08553927bdd6ba3654da0010289b4/cryptography-45.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9024beb59aca9d31d36fcdc1604dd9bbeed0a55bface9f1908df19178e2f116e", size = 4332873, upload-time = "2025-07-02T13:05:18.743Z" }, + { url = "https://files.pythonhosted.org/packages/03/c2/840e0710da5106a7c3d4153c7215b2736151bba60bf4491bdb421df5056d/cryptography-45.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91098f02ca81579c85f66df8a588c78f331ca19089763d733e34ad359f474174", size = 4564651, upload-time = "2025-07-02T13:05:21.382Z" }, + { url = "https://files.pythonhosted.org/packages/2e/92/cc723dd6d71e9747a887b94eb3827825c6c24b9e6ce2bb33b847d31d5eaa/cryptography-45.0.5-cp311-abi3-win32.whl", hash = "sha256:926c3ea71a6043921050eaa639137e13dbe7b4ab25800932a8498364fc1abec9", size = 2929050, upload-time = "2025-07-02T13:05:23.39Z" }, + { url = "https://files.pythonhosted.org/packages/1f/10/197da38a5911a48dd5389c043de4aec4b3c94cb836299b01253940788d78/cryptography-45.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:b85980d1e345fe769cfc57c57db2b59cff5464ee0c045d52c0df087e926fbe63", size = 3403224, upload-time = "2025-07-02T13:05:25.202Z" }, + { url = "https://files.pythonhosted.org/packages/fe/2b/160ce8c2765e7a481ce57d55eba1546148583e7b6f85514472b1d151711d/cryptography-45.0.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3562c2f23c612f2e4a6964a61d942f891d29ee320edb62ff48ffb99f3de9ae8", size = 7017143, upload-time = "2025-07-02T13:05:27.229Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e7/2187be2f871c0221a81f55ee3105d3cf3e273c0a0853651d7011eada0d7e/cryptography-45.0.5-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fcfbefc4a7f332dece7272a88e410f611e79458fab97b5efe14e54fe476f4fd", size = 4197780, upload-time = "2025-07-02T13:05:29.299Z" }, + { url = "https://files.pythonhosted.org/packages/b9/cf/84210c447c06104e6be9122661159ad4ce7a8190011669afceeaea150524/cryptography-45.0.5-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:460f8c39ba66af7db0545a8c6f2eabcbc5a5528fc1cf6c3fa9a1e44cec33385e", size = 4420091, upload-time = "2025-07-02T13:05:31.221Z" }, + { url = "https://files.pythonhosted.org/packages/3e/6a/cb8b5c8bb82fafffa23aeff8d3a39822593cee6e2f16c5ca5c2ecca344f7/cryptography-45.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9b4cf6318915dccfe218e69bbec417fdd7c7185aa7aab139a2c0beb7468c89f0", size = 4198711, upload-time = "2025-07-02T13:05:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/04/f7/36d2d69df69c94cbb2473871926daf0f01ad8e00fe3986ac3c1e8c4ca4b3/cryptography-45.0.5-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2089cc8f70a6e454601525e5bf2779e665d7865af002a5dec8d14e561002e135", size = 3883299, upload-time = "2025-07-02T13:05:34.94Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f0ea40f016de72f81288e9fe8d1f6748036cb5ba6118774317a3ffc6022d/cryptography-45.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0027d566d65a38497bc37e0dd7c2f8ceda73597d2ac9ba93810204f56f52ebc7", size = 4450558, upload-time = "2025-07-02T13:05:37.288Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/94b504dc1a3cdf642d710407c62e86296f7da9e66f27ab12a1ee6fdf005b/cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:be97d3a19c16a9be00edf79dca949c8fa7eff621763666a145f9f9535a5d7f42", size = 4198020, upload-time = "2025-07-02T13:05:39.102Z" }, + { url = "https://files.pythonhosted.org/packages/05/2b/aaf0adb845d5dabb43480f18f7ca72e94f92c280aa983ddbd0bcd6ecd037/cryptography-45.0.5-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7760c1c2e1a7084153a0f68fab76e754083b126a47d0117c9ed15e69e2103492", size = 4449759, upload-time = "2025-07-02T13:05:41.398Z" }, + { url = "https://files.pythonhosted.org/packages/91/e4/f17e02066de63e0100a3a01b56f8f1016973a1d67551beaf585157a86b3f/cryptography-45.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ff8728d8d890b3dda5765276d1bc6fb099252915a2cd3aff960c4c195745dd0", size = 4319991, upload-time = "2025-07-02T13:05:43.64Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2e/e2dbd629481b499b14516eed933f3276eb3239f7cee2dcfa4ee6b44d4711/cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a", size = 4554189, upload-time = "2025-07-02T13:05:46.045Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ea/a78a0c38f4c8736287b71c2ea3799d173d5ce778c7d6e3c163a95a05ad2a/cryptography-45.0.5-cp37-abi3-win32.whl", hash = "sha256:1e1da5accc0c750056c556a93c3e9cb828970206c68867712ca5805e46dc806f", size = 2911769, upload-time = "2025-07-02T13:05:48.329Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/28ac139109d9005ad3f6b6f8976ffede6706a6478e21c889ce36c840918e/cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97", size = 3390016, upload-time = "2025-07-02T13:05:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8b/34394337abe4566848a2bd49b26bcd4b07fd466afd3e8cce4cb79a390869/cryptography-45.0.5-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:206210d03c1193f4e1ff681d22885181d47efa1ab3018766a7b32a7b3d6e6afd", size = 3575762, upload-time = "2025-07-02T13:05:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5d/a19441c1e89afb0f173ac13178606ca6fab0d3bd3ebc29e9ed1318b507fc/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c648025b6840fe62e57107e0a25f604db740e728bd67da4f6f060f03017d5097", size = 4140906, upload-time = "2025-07-02T13:05:55.914Z" }, + { url = "https://files.pythonhosted.org/packages/4b/db/daceb259982a3c2da4e619f45b5bfdec0e922a23de213b2636e78ef0919b/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b8fa8b0a35a9982a3c60ec79905ba5bb090fc0b9addcfd3dc2dd04267e45f25e", size = 4374411, upload-time = "2025-07-02T13:05:57.814Z" }, + { url = "https://files.pythonhosted.org/packages/6a/35/5d06ad06402fc522c8bf7eab73422d05e789b4e38fe3206a85e3d6966c11/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:14d96584701a887763384f3c47f0ca7c1cce322aa1c31172680eb596b890ec30", size = 4140942, upload-time = "2025-07-02T13:06:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/65/79/020a5413347e44c382ef1f7f7e7a66817cd6273e3e6b5a72d18177b08b2f/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57c816dfbd1659a367831baca4b775b2a5b43c003daf52e9d57e1d30bc2e1b0e", size = 4374079, upload-time = "2025-07-02T13:06:02.043Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c5/c0e07d84a9a2a8a0ed4f865e58f37c71af3eab7d5e094ff1b21f3f3af3bc/cryptography-45.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b9e38e0a83cd51e07f5a48ff9691cae95a79bea28fe4ded168a8e5c6c77e819d", size = 3321362, upload-time = "2025-07-02T13:06:04.463Z" }, + { url = "https://files.pythonhosted.org/packages/c0/71/9bdbcfd58d6ff5084687fe722c58ac718ebedbc98b9f8f93781354e6d286/cryptography-45.0.5-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8c4a6ff8a30e9e3d38ac0539e9a9e02540ab3f827a3394f8852432f6b0ea152e", size = 3587878, upload-time = "2025-07-02T13:06:06.339Z" }, + { url = "https://files.pythonhosted.org/packages/f0/63/83516cfb87f4a8756eaa4203f93b283fda23d210fc14e1e594bd5f20edb6/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bd4c45986472694e5121084c6ebbd112aa919a25e783b87eb95953c9573906d6", size = 4152447, upload-time = "2025-07-02T13:06:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/22/11/d2823d2a5a0bd5802b3565437add16f5c8ce1f0778bf3822f89ad2740a38/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:982518cd64c54fcada9d7e5cf28eabd3ee76bd03ab18e08a48cad7e8b6f31b18", size = 4386778, upload-time = "2025-07-02T13:06:10.263Z" }, + { url = "https://files.pythonhosted.org/packages/5f/38/6bf177ca6bce4fe14704ab3e93627c5b0ca05242261a2e43ef3168472540/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:12e55281d993a793b0e883066f590c1ae1e802e3acb67f8b442e721e475e6463", size = 4151627, upload-time = "2025-07-02T13:06:13.097Z" }, + { url = "https://files.pythonhosted.org/packages/38/6a/69fc67e5266bff68a91bcb81dff8fb0aba4d79a78521a08812048913e16f/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:5aa1e32983d4443e310f726ee4b071ab7569f58eedfdd65e9675484a4eb67bd1", size = 4385593, upload-time = "2025-07-02T13:06:15.689Z" }, + { url = "https://files.pythonhosted.org/packages/f6/34/31a1604c9a9ade0fdab61eb48570e09a796f4d9836121266447b0eaf7feb/cryptography-45.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e357286c1b76403dd384d938f93c46b2b058ed4dfcdce64a770f0537ed3feb6f", size = 3331106, upload-time = "2025-07-02T13:06:18.058Z" }, +] + [[package]] name = "cssselect2" version = "0.8.0" @@ -582,6 +631,7 @@ dependencies = [ { name = "jsonschema" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "sse-starlette" }, @@ -629,6 +679,7 @@ requires-dist = [ { name = "jsonschema", specifier = ">=4.20.0" }, { name = "pydantic", specifier = ">=2.8.0,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.5.2" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10.1" }, { name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" }, { name = "python-multipart", specifier = ">=0.0.9" }, { name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=310" }, @@ -702,6 +753,68 @@ dev = [ { name = "ruff", specifier = ">=0.8.5" }, ] +[[package]] +name = "mcp-simple-auth-client" +version = "0.1.0" +source = { editable = "examples/clients/simple-auth-client" } +dependencies = [ + { name = "click" }, + { name = "mcp" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.0.0" }, + { name = "mcp", editable = "." }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.379" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + +[[package]] +name = "mcp-simple-chatbot" +version = "0.1.0" +source = { editable = "examples/clients/simple-chatbot" } +dependencies = [ + { name = "mcp" }, + { name = "python-dotenv" }, + { name = "requests" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "mcp", editable = "." }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "requests", specifier = ">=2.31.0" }, + { name = "uvicorn", specifier = ">=0.32.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.379" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + [[package]] name = "mcp-simple-prompt" version = "0.1.0" @@ -1302,6 +1415,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pymdown-extensions" version = "10.16"