Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions docs/integrations/azure.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -326,3 +326,135 @@ mcp = FastMCP(name="Azure MI App", auth=auth)
<Note>
For Azure Government, pass `base_authority="login.microsoftonline.us"` to `AzureJWTVerifier`.
</Note>

## On-Behalf-Of (OBO)

<VersionBadge version="3.0.0" />

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.

<Note>
OBO features require the `azure` extra:

```bash
pip install 'fastmcp[azure]'
```
</Note>

### Azure Portal Setup

OBO requires additional configuration in your Azure App registration beyond basic authentication.

<Steps>
<Step title="Add API Permissions">
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

<Warning>
Only add delegated permissions for OBO. Application permissions bypass user context entirely and are inappropriate for the OBO flow.
</Warning>
</Step>

<Step title="Grant Admin Consent">
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.

<Tip>
For development, you can grant consent for just your own account. For production, an Azure AD administrator must grant tenant-wide consent.
</Tip>
</Step>
</Steps>

### 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=[
Copy link
Copy Markdown

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.

Copy link
Copy Markdown
Contributor

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.

Copy link
Copy Markdown
Contributor

@JonasKs JonasKs Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But — additional_authorize_scopes isn't just for graph scopes, so we should remove that comment! 😊

Copy link
Copy Markdown

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?

"https://graph.microsoft.com/Mail.Read",
"https://graph.microsoft.com/User.Read",
"offline_access", # Enables refresh tokens
],
)
Comment thread
jlowin marked this conversation as resolved.

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.

<Info>
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.
</Info>

### 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", [])
]
Comment thread
jlowin marked this conversation as resolved.
```

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.

<Warning>
**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.
</Warning>

<Tip>
For advanced OBO scenarios, use `CurrentAccessToken()` to get the user's token, then construct an `azure.identity.aio.OnBehalfOfCredential` directly with your Azure credentials.
</Tip>

<Tip>
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).
</Tip>
31 changes: 31 additions & 0 deletions docs/servers/dependency-injection.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Make the TokenClaim example runnable and show failure behavior.
The snippet uses an undefined db and doesn’t show how missing-claim errors surface. The MDX guidelines require runnable examples with error handling and expected outcomes.

✅ 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.

As per coding guidelines, MDX examples must be runnable, include error handling guidance, and show expected outputs.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### 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.
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:
# 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>"}


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" />
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/fastmcp/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
CurrentWorker,
Progress,
ProgressLike,
TokenClaim,
)

__all__ = [
Expand All @@ -39,4 +40,5 @@
"Depends",
"Progress",
"ProgressLike",
"TokenClaim",
]
148 changes: 147 additions & 1 deletion src/fastmcp/server/auth/providers/azure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add missing return type annotation.
_EntraOBOToken.__init__ needs -> None to satisfy the full-annotation rule for src/**/*.py.

✅ Suggested fix
-    def __init__(self, scopes: list[str]):
+    def __init__(self, scopes: list[str]) -> None:
         self.scopes = scopes
As per coding guidelines, ensure full type annotations in `src/**/*.py`.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def __init__(self, scopes: list[str]):
self.scopes = scopes
def __init__(self, scopes: list[str]) -> None:
self.scopes = scopes

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
Comment thread
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))
Loading
Loading