-
Notifications
You must be signed in to change notification settings - Fork 2k
Add Azure OBO dependencies, auth token injection, and documentation #2918
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0b63eb5
0ce306f
12f6760
fc5b094
352d2c5
ee8b1f5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+240
to
+260
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make the TokenClaim example runnable and show failure behavior. ✅ Possible update from fastmcp import FastMCP
from fastmcp.server.dependencies import TokenClaim
mcp = FastMCP("Demo")
+_expenses: list[dict[str, object]] = []
`@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 errors are raised before this function runs.
+ _expenses.append({"user_id": user_id, "amount": amount})
+ return {"status": "created", "user_id": user_id}
+
+# Expected result: {"status": "created", "user_id": "<oid>"}Add a brief note explaining that TokenClaim failures surface as runtime errors and how clients should handle them. 📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <VersionBadge version="2.3.0" /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||
|
|
||||||||||||
|
Comment on lines
+617
to
+620
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add missing return type annotation. ✅ Suggested fix- def __init__(self, scopes: list[str]):
+ def __init__(self, scopes: list[str]) -> None:
self.scopes = scopes📝 Committable suggestion
Suggested change
|
||||||||||||
| 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 | ||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||
|
|
||||||||||||
| 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)) | ||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this still necessary, given admin consent? I don't believe I needed to do this in my implementation, since admin consent is given on the Entra app registration that has those scopes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I answered below, but admin consent isn't a requirement, nor should we make it one.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But —
additional_authorize_scopesisn't just forgraph scopes, so we should remove that comment! 😊There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
True, yes. I think it's a bit confusing since the section just before here describes how to grant admin consent. Perhaps a note here that if you have granted admin consent to the Entra app registration, additional_authorize_scopes may not be necessary?