diff --git a/docs/integrations/azure.mdx b/docs/integrations/azure.mdx index a5b316d881..4376a38ce0 100644 --- a/docs/integrations/azure.mdx +++ b/docs/integrations/azure.mdx @@ -326,3 +326,135 @@ mcp = FastMCP(name="Azure MI App", auth=auth) For Azure Government, pass `base_authority="login.microsoftonline.us"` to `AzureJWTVerifier`. + +## On-Behalf-Of (OBO) + + + +The On-Behalf-Of (OBO) flow allows your FastMCP server to call downstream Microsoft APIs—like Microsoft Graph—using the authenticated user's identity. When a user authenticates to your MCP server, you receive a token for your API. OBO exchanges that token for a new token that can call other services, maintaining the user's identity and permissions throughout the chain. + +This pattern is useful when your tools need to access user-specific data from Microsoft services: reading emails, accessing calendar events, querying SharePoint, or any other Graph API operation that requires user context. + + +OBO features require the `azure` extra: + +```bash +pip install 'fastmcp[azure]' +``` + + +### Azure Portal Setup + +OBO requires additional configuration in your Azure App registration beyond basic authentication. + + + + In your App registration, navigate to **API permissions** and add the Microsoft Graph permissions your tools will need. + + - Click **Add a permission** → **Microsoft Graph** → **Delegated permissions** + - Select the permissions required for your use case (e.g., `Mail.Read`, `Calendars.Read`, `User.Read`) + - Repeat for any other APIs you need to call + + + Only add delegated permissions for OBO. Application permissions bypass user context entirely and are inappropriate for the OBO flow. + + + + + OBO requires admin consent for the permissions you've added. In the **API permissions** page, click **Grant admin consent for [Your Organization]**. + + Without admin consent, OBO token exchanges will fail with an `AADSTS65001` error indicating the user or administrator hasn't consented to use the application. + + + For development, you can grant consent for just your own account. For production, an Azure AD administrator must grant tenant-wide consent. + + + + +### Configure AzureProvider for OBO + +The `additional_authorize_scopes` parameter tells Azure which downstream API permissions to include during the initial authorization. These scopes establish what your server can request through OBO later. + +```python server.py +from fastmcp import FastMCP +from fastmcp.server.auth.providers.azure import AzureProvider + +auth_provider = AzureProvider( + client_id="your-client-id", + client_secret="your-client-secret", + tenant_id="your-tenant-id", + base_url="http://localhost:8000", + required_scopes=["mcp-access"], # Your API scope + # Include Graph scopes for OBO + additional_authorize_scopes=[ + "https://graph.microsoft.com/Mail.Read", + "https://graph.microsoft.com/User.Read", + "offline_access", # Enables refresh tokens + ], +) + +mcp = FastMCP(name="Graph-Enabled Server", auth=auth_provider) +``` + +Scopes listed in `additional_authorize_scopes` are requested during the initial OAuth flow but aren't validated on incoming tokens. They establish permission for your server to later exchange the user's token for downstream API access. + + +Use fully-qualified scope URIs for downstream APIs (e.g., `https://graph.microsoft.com/Mail.Read`). Short forms like `Mail.Read` work for authorization requests, but fully-qualified URIs are clearer and avoid ambiguity. + + +### EntraOBOToken Dependency + +The `EntraOBOToken` dependency handles the complete OBO flow automatically. Declare it as a parameter default with the scopes you need, and FastMCP exchanges the user's token for a downstream API token before your function runs. + +```python +from fastmcp import FastMCP +from fastmcp.server.auth.providers.azure import AzureProvider, EntraOBOToken +import httpx + +auth_provider = AzureProvider( + client_id="your-client-id", + client_secret="your-client-secret", + tenant_id="your-tenant-id", + base_url="http://localhost:8000", + required_scopes=["mcp-access"], + additional_authorize_scopes=[ + "https://graph.microsoft.com/Mail.Read", + "https://graph.microsoft.com/User.Read", + ], +) + +mcp = FastMCP(name="Email Reader", auth=auth_provider) + +@mcp.tool +async def get_recent_emails( + count: int = 10, + graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"]), +) -> list[dict]: + """Get the user's recent emails from Microsoft Graph.""" + async with httpx.AsyncClient() as client: + response = await client.get( + f"https://graph.microsoft.com/v1.0/me/messages?$top={count}", + headers={"Authorization": f"Bearer {graph_token}"}, + ) + response.raise_for_status() + data = response.json() + + return [ + {"subject": msg["subject"], "from": msg["from"]["emailAddress"]["address"]} + for msg in data.get("value", []) + ] +``` + +The `graph_token` parameter receives a ready-to-use access token for Microsoft Graph. FastMCP handles the OBO exchange transparently—your function just uses the token to call the API. + + +**Scope alignment is critical.** The scopes passed to `EntraOBOToken` must be a subset of the scopes in `additional_authorize_scopes`. If you request a scope during OBO that wasn't included in the initial authorization, the exchange will fail. + + + +For advanced OBO scenarios, use `CurrentAccessToken()` to get the user's token, then construct an `azure.identity.aio.OnBehalfOfCredential` directly with your Azure credentials. + + + +For a complete working example of Azure OBO with FastMCP, see [Pamela Fox's blog post on OBO flow for Entra-based MCP servers](https://blog.pamelafox.org/2026/01/using-on-behalf-of-flow-for-entra-based.html). + diff --git a/docs/servers/dependency-injection.mdx b/docs/servers/dependency-injection.mdx index d986bc52a2..27dd3fd0cc 100644 --- a/docs/servers/dependency-injection.mdx +++ b/docs/servers/dependency-injection.mdx @@ -237,6 +237,37 @@ The `AccessToken` object provides: - **`expires_at`**: Token expiration timestamp (if available) - **`claims`**: Dictionary of all token claims (JWT claims or provider-specific data) +### Token Claims + +When you need just one specific value from the token—like a user ID or tenant identifier—`TokenClaim()` extracts it directly without needing the full token object. + +```python +from fastmcp import FastMCP +from fastmcp.server.dependencies import TokenClaim + +mcp = FastMCP("Demo") + + +@mcp.tool +async def add_expense( + amount: float, + user_id: str = TokenClaim("oid"), # Azure object ID +) -> dict: + await db.insert({"user_id": user_id, "amount": amount}) + return {"status": "created", "user_id": user_id} +``` + +`TokenClaim()` raises a `RuntimeError` if the claim doesn't exist, listing available claims to help with debugging. + +Common claims vary by identity provider: + +| Provider | User ID Claim | Email Claim | Name Claim | +|----------|--------------|-------------|------------| +| Azure/Entra | `oid` | `email` | `name` | +| GitHub | `sub` | `email` | `name` | +| Google | `sub` | `email` | `name` | +| Auth0 | `sub` | `email` | `name` | + ### Background Task Dependencies diff --git a/pyproject.toml b/pyproject.toml index d1a7c58146..4c0eb22f91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,13 +52,14 @@ classifiers = [ [project.optional-dependencies] anthropic = ["anthropic>=0.40.0"] +azure = ["azure-identity>=1.16.0"] openai = ["openai>=1.102.0"] tasks = ["pydocket>=0.17.2"] [dependency-groups] dev = [ "dirty-equals>=0.9.0", - "fastmcp[anthropic,openai,tasks]", + "fastmcp[anthropic,azure,openai,tasks]", # add optional dependencies for fastmcp dev "fastapi>=0.115.12", "opentelemetry-sdk>=1.20.0", diff --git a/src/fastmcp/dependencies.py b/src/fastmcp/dependencies.py index 87d5367a92..b23222e9dc 100644 --- a/src/fastmcp/dependencies.py +++ b/src/fastmcp/dependencies.py @@ -26,6 +26,7 @@ CurrentWorker, Progress, ProgressLike, + TokenClaim, ) __all__ = [ @@ -39,4 +40,5 @@ "Depends", "Progress", "ProgressLike", + "TokenClaim", ] diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index cc0d2544e9..b5974d8874 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -6,7 +6,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from key_value.aio.protocols import AsyncKeyValue @@ -16,6 +16,7 @@ from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: + from azure.identity.aio import OnBehalfOfCredential from mcp.server.auth.provider import AuthorizationParams from mcp.shared.auth import OAuthClientInformationFull @@ -161,6 +162,10 @@ def __init__( if "offline_access" not in parsed_additional_scopes: parsed_additional_scopes = [*parsed_additional_scopes, "offline_access"] + # Store Azure-specific config for OBO credential creation + self._tenant_id = tenant_id + self._base_authority = base_authority + # Apply defaults self.identifier_uri = identifier_uri or f"api://{client_id}" self.additional_authorize_scopes: list[str] = parsed_additional_scopes @@ -453,6 +458,33 @@ async def _extract_upstream_claims( logger.debug("Failed to extract Azure claims: %s", e) return None + def create_obo_credential(self, user_assertion: str) -> OnBehalfOfCredential: + """Create an OnBehalfOfCredential for OBO token exchange. + + Uses the AzureProvider's configuration (client_id, client_secret, + tenant_id, authority) to create a credential that can exchange the + user's token for downstream API tokens. + + Args: + user_assertion: The user's access token to exchange via OBO. + + Returns: + A configured OnBehalfOfCredential ready for get_token() calls. + + Raises: + ImportError: If azure-identity is not installed (requires fastmcp[azure]). + """ + _require_azure_identity("OBO token exchange") + from azure.identity.aio import OnBehalfOfCredential + + return OnBehalfOfCredential( + tenant_id=self._tenant_id, + client_id=self._upstream_client_id, + client_secret=self._upstream_client_secret.get_secret_value(), + user_assertion=user_assertion, + authority=f"https://{self._base_authority}", + ) + class AzureJWTVerifier(JWTVerifier): """JWT verifier pre-configured for Azure AD / Microsoft Entra ID. @@ -552,3 +584,117 @@ def scopes_supported(self) -> list[str]: else: prefixed.append(f"{self._identifier_uri}/{scope}") return prefixed + + +# --- Dependency injection support --- +# These require fastmcp[azure] extra for azure-identity + +# Check if DI engine is available +try: + from docket.dependencies import Dependency +except ImportError: + from fastmcp._vendor.docket_di import Dependency + + +def _require_azure_identity(feature: str) -> None: + """Raise ImportError with install instructions if azure-identity is not available.""" + try: + import azure.identity # noqa: F401 + except ImportError as e: + raise ImportError( + f"{feature} requires the `azure` extra. " + "Install with: pip install 'fastmcp[azure]'" + ) from e + + +class _EntraOBOToken(Dependency): # type: ignore[misc] + """Dependency that performs OBO token exchange for Microsoft Entra. + + Uses azure.identity's OnBehalfOfCredential for async-native OBO, + with automatic token caching and refresh. + """ + + def __init__(self, scopes: list[str]): + self.scopes = scopes + self._credential: OnBehalfOfCredential | None = None + + async def __aenter__(self) -> str: + _require_azure_identity("EntraOBOToken") + + from fastmcp.server.dependencies import get_access_token, get_server + + access_token = get_access_token() + if access_token is None: + raise RuntimeError( + "No access token available. Cannot perform OBO exchange." + ) + + server = get_server() + if not isinstance(server.auth, AzureProvider): + raise RuntimeError( + "EntraOBOToken requires an AzureProvider as the auth provider. " + f"Current provider: {type(server.auth).__name__}" + ) + + self._credential = server.auth.create_obo_credential( + user_assertion=access_token.token, + ) + + try: + result = await self._credential.get_token(*self.scopes) + except BaseException: + await self._credential.close() + self._credential = None + raise + + return result.token + + async def __aexit__(self, *args: object) -> None: + if self._credential is not None: + await self._credential.close() + self._credential = None + + +def EntraOBOToken(scopes: list[str]) -> str: + """Exchange the user's Entra token for a downstream API token via OBO. + + This dependency performs a Microsoft Entra On-Behalf-Of (OBO) token exchange, + allowing your MCP server to call downstream APIs (like Microsoft Graph) on + behalf of the authenticated user. + + Args: + scopes: The scopes to request for the downstream API. For Microsoft Graph, + use scopes like ["https://graph.microsoft.com/Mail.Read"] or + ["https://graph.microsoft.com/.default"]. + + Returns: + A dependency that resolves to the downstream API access token string + + Raises: + ImportError: If fastmcp[azure] is not installed + RuntimeError: If no access token is available, provider is not Azure, + or OBO exchange fails + + Example: + ```python + from fastmcp.server.auth.providers.azure import EntraOBOToken + import httpx + + @mcp.tool() + async def get_my_emails( + graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"]) + ): + async with httpx.AsyncClient() as client: + resp = await client.get( + "https://graph.microsoft.com/v1.0/me/messages", + headers={"Authorization": f"Bearer {graph_token}"} + ) + return resp.json() + ``` + + Note: + For OBO to work, ensure the scopes are included in the AzureProvider's + `additional_authorize_scopes` parameter, and that admin consent has been + granted for those scopes in your Entra app registration. + """ + return cast(str, _EntraOBOToken(scopes)) diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index ffef2561bb..97cf4fbeff 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -53,6 +53,7 @@ "CurrentWorker", "Progress", "TaskContextInfo", + "TokenClaim", "get_access_token", "get_context", "get_http_headers", @@ -991,47 +992,6 @@ async def get_auth_type(headers: dict = CurrentHeaders()) -> str: return cast(dict[str, str], _CurrentHeaders()) -class _CurrentAccessToken(Dependency): # type: ignore[misc] - """Async context manager for AccessToken dependency.""" - - async def __aenter__(self) -> AccessToken: - token = get_access_token() - if token is None: - raise RuntimeError( - "No access token found. Ensure authentication is configured " - "and the request is authenticated." - ) - return token - - async def __aexit__(self, *args: object) -> None: - pass - - -def CurrentAccessToken() -> AccessToken: - """Get the current access token for the authenticated user. - - This dependency provides access to the AccessToken for the current - authenticated request. Raises an error if no authentication is present. - - Returns: - A dependency that resolves to the active AccessToken - - Raises: - RuntimeError: If no authenticated user (use get_access_token() for optional) - - Example: - ```python - from fastmcp.server.dependencies import CurrentAccessToken - from fastmcp.server.auth import AccessToken - - @mcp.tool() - async def get_user_id(token: AccessToken = CurrentAccessToken()) -> str: - return token.claims.get("sub", "unknown") - ``` - """ - return cast(AccessToken, _CurrentAccessToken()) - - # --- Progress dependency --- @@ -1162,3 +1122,106 @@ async def __aenter__(self) -> ProgressLike: async def __aexit__(self, *args: object) -> None: pass + + +# --- Access Token dependency --- + + +class _CurrentAccessToken(Dependency): # type: ignore[misc] + """Async context manager for AccessToken dependency.""" + + async def __aenter__(self) -> AccessToken: + token = get_access_token() + if token is None: + raise RuntimeError( + "No access token found. Ensure authentication is configured " + "and the request is authenticated." + ) + return token + + async def __aexit__(self, *args: object) -> None: + pass + + +def CurrentAccessToken() -> AccessToken: + """Get the current access token for the authenticated user. + + This dependency provides access to the AccessToken for the current + authenticated request. Raises an error if no authentication is present. + + Returns: + A dependency that resolves to the active AccessToken + + Raises: + RuntimeError: If no authenticated user (use get_access_token() for optional) + + Example: + ```python + from fastmcp.server.dependencies import CurrentAccessToken + from fastmcp.server.auth import AccessToken + + @mcp.tool() + async def get_user_id(token: AccessToken = CurrentAccessToken()) -> str: + return token.claims.get("sub", "unknown") + ``` + """ + return cast(AccessToken, _CurrentAccessToken()) + + +# --- Token Claim dependency --- + + +class _TokenClaim(Dependency): # type: ignore[misc] + """Dependency that extracts a specific claim from the access token.""" + + def __init__(self, claim_name: str): + self.claim_name = claim_name + + async def __aenter__(self) -> str: + token = get_access_token() + if token is None: + raise RuntimeError( + f"No access token available. Cannot extract claim '{self.claim_name}'." + ) + value = token.claims.get(self.claim_name) + if value is None: + raise RuntimeError( + f"Claim '{self.claim_name}' not found in access token. " + f"Available claims: {list(token.claims.keys())}" + ) + return str(value) + + async def __aexit__(self, *args: object) -> None: + pass + + +def TokenClaim(name: str) -> str: + """Get a specific claim from the access token. + + This dependency extracts a single claim value from the current access token. + It's useful for getting user identifiers, roles, or other token claims + without needing the full token object. + + Args: + name: The name of the claim to extract (e.g., "oid", "sub", "email") + + Returns: + A dependency that resolves to the claim value as a string + + Raises: + RuntimeError: If no access token is available or claim is missing + + Example: + ```python + from fastmcp.server.dependencies import TokenClaim + + @mcp.tool() + async def add_expense( + user_id: str = TokenClaim("oid"), # Azure object ID + amount: float, + ): + # user_id is automatically injected from the token + await db.insert({"user_id": user_id, "amount": amount}) + ``` + """ + return cast(str, _TokenClaim(name)) diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py index 6bf25a50df..9542c19f99 100644 --- a/tests/server/auth/providers/test_azure.py +++ b/tests/server/auth/providers/test_azure.py @@ -1122,3 +1122,99 @@ def test_specific_tenant_sets_issuer(self): verifier.issuer == "https://login.microsoftonline.com/12345678-1234-1234-1234-123456789012/v2.0" ) + + +class TestAzureOBOIntegration: + """Tests for azure.identity OBO integration (create_obo_credential, EntraOBOToken).""" + + def test_create_obo_credential_returns_configured_credential(self): + """Test that create_obo_credential returns a properly configured credential.""" + from unittest.mock import MagicMock, patch + + provider = AzureProvider( + client_id="test-client-id", + client_secret="test-client-secret", + tenant_id="test-tenant-id", + base_url="https://myserver.com", + required_scopes=["read"], + jwt_signing_key="test-secret", + ) + + mock_credential = MagicMock() + with patch( + "azure.identity.aio.OnBehalfOfCredential", return_value=mock_credential + ) as mock_class: + credential = provider.create_obo_credential(user_assertion="user-token-123") + + mock_class.assert_called_once_with( + tenant_id="test-tenant-id", + client_id="test-client-id", + client_secret="test-client-secret", + user_assertion="user-token-123", + authority="https://login.microsoftonline.com", + ) + assert credential is mock_credential + + def test_create_obo_credential_with_custom_authority(self): + """Test that create_obo_credential uses custom base_authority.""" + from unittest.mock import MagicMock, patch + + provider = AzureProvider( + client_id="test-client-id", + client_secret="test-client-secret", + tenant_id="gov-tenant-id", + base_url="https://myserver.com", + required_scopes=["read"], + base_authority="login.microsoftonline.us", + jwt_signing_key="test-secret", + ) + + mock_credential = MagicMock() + with patch( + "azure.identity.aio.OnBehalfOfCredential", return_value=mock_credential + ) as mock_class: + provider.create_obo_credential(user_assertion="user-token") + + call_kwargs = mock_class.call_args[1] + assert call_kwargs["authority"] == "https://login.microsoftonline.us" + + def test_tenant_and_authority_stored_as_attributes(self): + """Test that tenant_id and base_authority are stored for OBO credential creation.""" + provider = AzureProvider( + client_id="test-client-id", + client_secret="test-client-secret", + tenant_id="my-tenant", + base_url="https://myserver.com", + required_scopes=["read"], + base_authority="login.microsoftonline.us", + jwt_signing_key="test-secret", + ) + + assert provider._tenant_id == "my-tenant" + assert provider._base_authority == "login.microsoftonline.us" + + def test_entra_obo_token_is_importable(self): + """Test that EntraOBOToken can be imported.""" + from fastmcp.server.auth.providers.azure import EntraOBOToken + + assert EntraOBOToken is not None + + def test_entra_obo_token_creates_dependency(self): + """Test that EntraOBOToken creates a dependency with scopes.""" + from fastmcp.server.auth.providers.azure import EntraOBOToken, _EntraOBOToken + + dep = EntraOBOToken(["https://graph.microsoft.com/User.Read"]) + assert isinstance(dep, _EntraOBOToken) + assert dep.scopes == ["https://graph.microsoft.com/User.Read"] + + def test_entra_obo_token_is_dependency_instance(self): + """Test that EntraOBOToken is a Dependency instance.""" + try: + from docket.dependencies import Dependency + except ImportError: + from fastmcp._vendor.docket_di import Dependency + + from fastmcp.server.auth.providers.azure import _EntraOBOToken + + dep = _EntraOBOToken(["scope"]) + assert isinstance(dep, Dependency) diff --git a/tests/server/test_dependencies.py b/tests/server/test_dependencies.py index 7d4119c8e4..106babecce 100644 --- a/tests/server/test_dependencies.py +++ b/tests/server/test_dependencies.py @@ -1045,3 +1045,115 @@ def my_func(name: str, db: str = Depends(get_db)) -> str: db_dep = deps["db"] assert isinstance(db_dep, _Depends) assert db_dep.dependency is get_db + + +class TestAuthDependencies: + """Tests for authentication dependencies (CurrentAccessToken, TokenClaim).""" + + def test_current_access_token_is_importable(self): + """Test that CurrentAccessToken can be imported.""" + from fastmcp.server.dependencies import CurrentAccessToken + + assert CurrentAccessToken is not None + + def test_token_claim_is_importable(self): + """Test that TokenClaim can be imported.""" + from fastmcp.server.dependencies import TokenClaim + + assert TokenClaim is not None + + def test_current_access_token_is_dependency(self): + """Test that CurrentAccessToken is a Dependency instance.""" + # Import the Dependency class the same way the code does + # (docket if available, vendored otherwise) + try: + from docket.dependencies import Dependency + except ImportError: + from fastmcp._vendor.docket_di import Dependency + + from fastmcp.server.dependencies import _CurrentAccessToken + + dep = _CurrentAccessToken() + assert isinstance(dep, Dependency) + + def test_token_claim_creates_dependency(self): + """Test that TokenClaim creates a Dependency instance.""" + # Import the Dependency class the same way the code does + try: + from docket.dependencies import Dependency + except ImportError: + from fastmcp._vendor.docket_di import Dependency + + from fastmcp.server.dependencies import TokenClaim, _TokenClaim + + dep = TokenClaim("oid") + assert isinstance(dep, _TokenClaim) + assert isinstance(dep, Dependency) + assert dep.claim_name == "oid" + + async def test_current_access_token_raises_without_token(self): + """Test that CurrentAccessToken raises when no token is available.""" + from fastmcp.server.dependencies import _CurrentAccessToken + + dep = _CurrentAccessToken() + with pytest.raises(RuntimeError, match="No access token found"): + await dep.__aenter__() + + async def test_token_claim_raises_without_token(self): + """Test that TokenClaim raises when no token is available.""" + from fastmcp.server.dependencies import _TokenClaim + + dep = _TokenClaim("oid") + with pytest.raises(RuntimeError, match="No access token available"): + await dep.__aenter__() + + async def test_current_access_token_excluded_from_tool_schema(self, mcp: FastMCP): + """Test that CurrentAccessToken dependency is excluded from tool schema.""" + import mcp.types as mcp_types + + from fastmcp.server.auth import AccessToken + from fastmcp.server.dependencies import CurrentAccessToken + + @mcp.tool() + async def tool_with_token( + name: str, + token: AccessToken = CurrentAccessToken(), + ) -> str: + return name + + result = await mcp._list_tools_mcp(mcp_types.ListToolsRequest()) + tool = next(t for t in result.tools if t.name == "tool_with_token") + + assert "name" in tool.inputSchema["properties"] + assert "token" not in tool.inputSchema["properties"] + + async def test_token_claim_excluded_from_tool_schema(self, mcp: FastMCP): + """Test that TokenClaim dependency is excluded from tool schema.""" + import mcp.types as mcp_types + + from fastmcp.server.dependencies import TokenClaim + + @mcp.tool() + async def tool_with_claim( + name: str, + user_id: str = TokenClaim("oid"), + ) -> str: + return name + + result = await mcp._list_tools_mcp(mcp_types.ListToolsRequest()) + tool = next(t for t in result.tools if t.name == "tool_with_claim") + + assert "name" in tool.inputSchema["properties"] + assert "user_id" not in tool.inputSchema["properties"] + + def test_current_access_token_exported_from_all(self): + """Test that CurrentAccessToken is exported from __all__.""" + from fastmcp.server import dependencies + + assert "CurrentAccessToken" in dependencies.__all__ + + def test_token_claim_exported_from_all(self): + """Test that TokenClaim is exported from __all__.""" + from fastmcp.server import dependencies + + assert "TokenClaim" in dependencies.__all__ diff --git a/uv.lock b/uv.lock index c158b7fb6c..3f329e1f32 100644 --- a/uv.lock +++ b/uv.lock @@ -97,6 +97,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" }, ] +[[package]] +name = "azure-core" +version = "1.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/1b/e503e08e755ea94e7d3419c9242315f888fc664211c90d032e40479022bf/azure_core-1.38.0.tar.gz", hash = "sha256:8194d2682245a3e4e3151a667c686464c3786fed7918b394d035bdcd61bb5993", size = 363033, upload-time = "2026-01-12T17:03:05.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/d8/b8fcba9464f02b121f39de2db2bf57f0b216fe11d014513d666e8634380d/azure_core-1.38.0-py3-none-any.whl", hash = "sha256:ab0c9b2cd71fecb1842d52c965c95285d3cfb38902f6766e4a471f1cd8905335", size = 217825, upload-time = "2026-01-12T17:03:07.291Z" }, +] + +[[package]] +name = "azure-identity" +version = "1.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "msal" }, + { name = "msal-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/8d/1a6c41c28a37eab26dc85ab6c86992c700cd3f4a597d9ed174b0e9c69489/azure_identity-1.25.1.tar.gz", hash = "sha256:87ca8328883de6036443e1c37b40e8dc8fb74898240f61071e09d2e369361456", size = 279826, upload-time = "2025-10-06T20:30:02.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/7b/5652771e24fff12da9dde4c20ecf4682e606b104f26419d139758cc935a6/azure_identity-1.25.1-py3-none-any.whl", hash = "sha256:e9edd720af03dff020223cd269fa3a61e8f345ea75443858273bcb44844ab651", size = 191317, upload-time = "2025-10-06T20:30:04.251Z" }, +] + [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -707,6 +736,9 @@ dependencies = [ anthropic = [ { name = "anthropic" }, ] +azure = [ + { name = "azure-identity" }, +] openai = [ { name = "openai" }, ] @@ -718,7 +750,7 @@ tasks = [ dev = [ { name = "dirty-equals" }, { name = "fastapi" }, - { name = "fastmcp", extra = ["anthropic", "openai", "tasks"] }, + { name = "fastmcp", extra = ["anthropic", "azure", "openai", "tasks"] }, { name = "inline-snapshot", extra = ["dirty-equals"] }, { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "ipython", version = "9.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -748,6 +780,7 @@ dev = [ requires-dist = [ { name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.40.0" }, { name = "authlib", specifier = ">=1.6.5" }, + { name = "azure-identity", marker = "extra == 'azure'", specifier = ">=1.16.0" }, { name = "cyclopts", specifier = ">=4.0.0" }, { name = "exceptiongroup", specifier = ">=1.2.2" }, { name = "httpx", specifier = ">=0.28.1,<1.0" }, @@ -770,13 +803,13 @@ requires-dist = [ { name = "watchfiles", specifier = ">=1.0.0" }, { name = "websockets", specifier = ">=15.0.1" }, ] -provides-extras = ["anthropic", "openai", "tasks"] +provides-extras = ["anthropic", "azure", "openai", "tasks"] [package.metadata.requires-dev] dev = [ { name = "dirty-equals", specifier = ">=0.9.0" }, { name = "fastapi", specifier = ">=0.115.12" }, - { name = "fastmcp", extras = ["anthropic", "openai", "tasks"] }, + { name = "fastmcp", extras = ["anthropic", "azure", "openai", "tasks"] }, { name = "inline-snapshot", extras = ["dirty-equals"], specifier = ">=0.27.2" }, { name = "ipython", specifier = ">=8.12.3" }, { name = "loq", specifier = ">=0.1.0a3" }, @@ -1413,6 +1446,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, ] +[[package]] +name = "msal" +version = "1.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/0e/c857c46d653e104019a84f22d4494f2119b4fe9f896c92b4b864b3b045cc/msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f", size = 153961, upload-time = "2025-09-22T23:05:48.989Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/dc/18d48843499e278538890dc709e9ee3dea8375f8be8e82682851df1b48b5/msal-1.34.0-py3-none-any.whl", hash = "sha256:f669b1644e4950115da7a176441b0e13ec2975c29528d8b9e81316023676d6e1", size = 116987, upload-time = "2025-09-22T23:05:47.294Z" }, +] + +[[package]] +name = "msal-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, +] + [[package]] name = "openai" version = "2.16.0"