Skip to content

Add Azure OBO dependencies, auth token injection, and documentation#2918

Merged
jlowin merged 6 commits intomainfrom
azure-obo-dependencies
Feb 10, 2026
Merged

Add Azure OBO dependencies, auth token injection, and documentation#2918
jlowin merged 6 commits intomainfrom
azure-obo-dependencies

Conversation

@jlowin
Copy link
Copy Markdown
Member

@jlowin jlowin commented Jan 19, 2026

Inspired by @pamelafox's blog post on OBO flow for Entra-based MCP servers.

FastMCP's OAuth Proxy pattern already enables On-Behalf-Of (OBO) flows with Azure/Entra - when a user authenticates, the upstream Azure token is stored and returned via get_access_token().token. But actually using that token for OBO required boilerplate: configuring MSAL, calling acquire_token_on_behalf_of, handling errors.

This PR adds dependency injection support and documentation that makes token access and OBO exchanges declarative.

Token Access (Any Auth Provider)

Extract the authenticated user's token or specific claims directly into your function parameters:

from fastmcp.server.dependencies import CurrentAccessToken, TokenClaim
from fastmcp.server.auth import AccessToken

@mcp.tool()
async def my_tool(
    token: AccessToken = CurrentAccessToken,  # Full token object
    user_id: str = TokenClaim("oid"),          # Just the claim you need
):
    # No get_access_token() boilerplate
    ...

Azure OBO (requires fastmcp[azure])

OBO typically requires setting up MSAL's ConfidentialClientApplication with your credentials, calling acquire_token_on_behalf_of with the user's assertion token, and handling the response. We provide dependencies that wrap all of this.

EntraOBOToken handles the complete exchange - just declare what scopes you need:

from fastmcp.server.auth.providers.azure import EntraOBOToken

@mcp.tool()
async def get_emails(
    graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"]),
):
    # graph_token is ready - OBO exchange happened automatically
    async with httpx.AsyncClient() as client:
        resp = await client.get(
            "https://graph.microsoft.com/v1.0/me/messages",
            headers={"Authorization": f"Bearer {graph_token}"}
        )

MSALApp provides direct access to a pre-configured MSAL client for custom scenarios:

from fastmcp.server.auth.providers.azure import MSALApp

@mcp.tool()
async def custom_exchange(msal: ConfidentialClientApplication = MSALApp):
    result = msal.acquire_token_on_behalf_of(
        user_assertion=get_access_token().token,
        scopes=["https://graph.microsoft.com/.default"],
    )
    # Handle result, caching, silent acquisition, etc.

Other Changes

  • Added fastmcp[azure] extra with msal dependency
  • Split context.mdx into separate Context and Dependencies doc pages

Related: #2447, #2495

@marvin-context-protocol marvin-context-protocol Bot added enhancement Improvement to existing functionality. For issues and smaller PR improvements. auth Related to authentication (Bearer, JWT, OAuth, WorkOS) for client or server. server Related to FastMCP server implementation or server-side functionality. labels Jan 19, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 19, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds Azure On-Behalf-Of (OBO) token-exchange support and a token-claim extraction dependency. Implements AzureProvider.create_obo_credential using runtime-guarded OnBehalfOfCredential, introduces an internal _EntraOBOToken dependency and a public EntraOBOToken(scopes) helper that returns downstream tokens, and exposes TokenClaim(name) to resolve individual access-token claims. Stores tenant and authority context on AzureProvider, adds DI scaffolding and import-time guards for optional azure-identity, and expands documentation (Azure OBO guide and TokenClaim usage). No removals or breaking public API changes.

Possibly related PRs

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Description check ⚠️ Warning The description is comprehensive and well-structured, covering objectives, examples, and implementation details; however, it lacks the Contributors Checklist completion required by the template. Complete all required checklist items in the Contributors and Review Checklist sections, including issue reference, workflow verification, testing, documentation updates, and self-review confirmation.
Docstring Coverage ⚠️ Warning Docstring coverage is 46.15% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main changes: adding Azure OBO dependencies, auth token injection, and documentation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch azure-obo-dependencies

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
src/fastmcp/server/dependencies.py (1)

1180-1192: str(value) silently flattens non-string claims (lists, dicts).

Claims like roles or groups are arrays. str(value) would return something like "['admin', 'reader']" which is unlikely to be useful. Consider documenting that TokenClaim is intended for scalar claims, or raising a TypeError for non-scalar values.

src/fastmcp/server/auth/providers/azure.py (2)

621-650: A new OnBehalfOfCredential is created on every DI resolution — consider noting this is intentional.

Each tool invocation creates and closes a fresh credential. Since OnBehalfOfCredential from azure-identity includes built-in caching internally, this should be fine for correctness, but there's a per-call overhead of constructing the credential object. If high-throughput scenarios are expected, consider caching credentials keyed by user_assertion (though assertions are short-lived, so this may not help much). The current approach is the simplest correct one.


599-607: Remove unused noqa directive on line 602.

Ruff flags # noqa: F401 as unnecessary here. The F401 rule may not be enabled in this project's Ruff config, making the directive a no-op.

Suggested fix
-        import azure.identity  # noqa: F401
+        import azure.identity
docs/servers/dependency-injection.mdx (1)

260-260: Add a brief note on how the error surfaces to clients.

The line mentions RuntimeError but doesn't explain the impact. A short note like "The error is raised before your function runs, so the tool call fails with an error response" helps users understand the behavior without needing to experiment.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Test Failure Analysis

Summary: Two tests in TestAuthDependencies are failing on Windows with Python 3.10 due to incorrect async/await usage.

Root Cause: The tests test_current_access_token_excluded_from_tool_schema and test_token_claim_excluded_from_tool_schema are defined as synchronous functions but try to use asyncio.get_event_loop().run_until_complete() to run async code. On Windows with Python 3.10, there is no event loop running in the main thread by default, causing RuntimeError: There is no current event loop in thread 'MainThread'.

Suggested Solution: Convert these two test functions to async and use await instead of run_until_complete():

File: tests/server/test_dependencies.py

  1. Line 1110: Change def test_current_access_token_excluded_from_tool_schema to async def test_current_access_token_excluded_from_tool_schema

  2. Line 1127-1129: Replace:

    result = asyncio.get_event_loop().run_until_complete(
        mcp._list_tools_mcp(mcp_types.ListToolsRequest())
    )

    with:

    result = await mcp._list_tools_mcp(mcp_types.ListToolsRequest())
  3. Line 1135: Change def test_token_claim_excluded_from_tool_schema to async def test_token_claim_excluded_from_tool_schema

  4. Line 1151-1153: Replace:

    result = asyncio.get_event_loop().run_until_complete(
        mcp._list_tools_mcp(mcp_types.ListToolsRequest())
    )

    with:

    result = await mcp._list_tools_mcp(mcp_types.ListToolsRequest())

This pattern matches the existing test_dependencies_excluded_from_schema test (line 127) which correctly uses async def and await.

Detailed Analysis

Error from logs:

FAILED tests/server/test_dependencies.py::TestAuthDependencies::test_current_access_token_excluded_from_tool_schema - RuntimeError: There is no current event loop in thread 'MainThread'.
FAILED tests/server/test_dependencies.py::TestAuthDependencies::test_token_claim_excluded_from_tool_schema - RuntimeError: There is no current event loop in thread 'MainThread'.

Stack trace shows:

File "C:\hostedtoolcache\windows\Python\3.10.11\x64\lib\asyncio\events.py", line 656
    raise RuntimeError('There is no current event loop in thread %r.' % threading.current_thread().name)
RuntimeError: There is no current event loop in thread 'MainThread'.

The issue is that in Python 3.10+ on Windows, asyncio.get_event_loop() no longer automatically creates an event loop if one doesn't exist. The proper approach is to define the test as async def so pytest-asyncio handles the event loop setup.

Related Files
  • tests/server/test_dependencies.py:1110 - First failing test
  • tests/server/test_dependencies.py:1135 - Second failing test
  • tests/server/test_dependencies.py:127 - Example of correct async test pattern

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d6f21fcddd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +539 to +542
# Perform the OBO exchange
result = msal_app.acquire_token_on_behalf_of(
user_assertion=access_token.token,
scopes=self.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.

P2 Badge Avoid blocking event loop during OBO exchange

msal_app.acquire_token_on_behalf_of(...) is a synchronous network call (MSAL uses requests under the hood), but it is invoked directly inside an async def __aenter__. In an async server, this blocks the event loop for the duration of the token exchange. Under slow Azure responses or concurrent calls, this can stall unrelated requests and hurt throughput. Consider offloading the MSAL call with call_sync_fn_in_threadpool or otherwise ensuring the OBO exchange runs outside the event loop.

Useful? React with 👍 / 👎.

@jlowin jlowin changed the title Add Azure OBO dependencies and auth token injection Add Azure OBO dependencies, auth token injection, and documentation Jan 19, 2026
@jlowin jlowin force-pushed the azure-obo-dependencies branch from 1b7fce0 to 3cdbc8b Compare January 22, 2026 14:38
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (2)
docs/integrations/azure.mdx (1)

281-456: Add a short verification step + expected outcome for OBO setup.
The new OBO section is solid, but the guide doesn’t include an explicit “verify” step or expected output, and several examples use placeholder IDs. The MDX guidelines call for verification steps and realistic data.

As per coding guidelines, include a small verification section (e.g., call the tool and show a successful response) and replace placeholders with realistic sample UUIDs already used earlier in the doc.

src/fastmcp/server/auth/providers/azure.py (1)

369-424: Cache the MSAL app to enable token reuse across requests.

get_msal_app() creates a new TokenCache and ConfidentialClientApplication on each call. MSAL best practices recommend reusing a single ConfidentialClientApplication instance to allow token caching and minimize instance discovery overhead. A simple lazy initialization cache on the provider will enable token reuse and improve OBO throughput.

Suggested implementation
 class AzureProvider(OAuthProxy):
     ...
+    _msal_app: ConfidentialClientApplication | None = None
 ...
     def get_msal_app(self) -> ConfidentialClientApplication:
         ...
-        return ConfidentialClientApplication(
-            client_id=self._upstream_client_id,
-            client_credential=self._upstream_client_secret.get_secret_value(),
-            authority=authority,
-            token_cache=TokenCache(),
-        )
+        if self._msal_app is None:
+            self._msal_app = ConfidentialClientApplication(
+                client_id=self._upstream_client_id,
+                client_credential=self._upstream_client_secret.get_secret_value(),
+                authority=authority,
+                token_cache=TokenCache(),
+            )
+        return self._msal_app

Comment on lines +240 to +260
### 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.
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>"}

Comment on lines +512 to +514
def __init__(self, scopes: list[str]):
self.scopes = 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.

⚠️ 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

Comment on lines +1036 to +1041
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

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

🧩 Analysis chain

🏁 Script executed:

# First, locate and read the specific lines from the file
head -n 1050 src/fastmcp/server/dependencies.py | tail -n 50

Repository: jlowin/fastmcp

Length of output: 172


🏁 Script executed:

# Get the full context of the _TokenClaim class
sed -n '1030,1060p' src/fastmcp/server/dependencies.py

Repository: jlowin/fastmcp

Length of output: 939


🏁 Script executed:

# Check for other __init__ methods in this file that might be missing -> None
rg -n "def __init__\(" src/fastmcp/server/dependencies.py -A 1 | head -100

Repository: jlowin/fastmcp

Length of output: 229


Add missing return type annotation.
The __init__ method lacks an explicit -> None return type annotation, which violates the full-annotation requirement for src/**/*.py.

✅ Suggested fix
-    def __init__(self, claim_name: str):
+    def __init__(self, claim_name: str) -> None:
         self.claim_name = claim_name

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?

@mvgadagi
Copy link
Copy Markdown

Hello @jlowin

Thank you for the PR and the nice FastMCP package. I wanted to get your inputs on a similar use case we’re working on.

We had setup FastMCP in proxy mode and connecting it to 2–3 MCP servers that rely on Azure AD authentication and it worked as POC where it managed for one client scenario, but we are yet to make changes for multiple clients using the FastMCP proxy
To support this, we’ve added a custom implementation for the On-Behalf-Of (OBO) flow, following the guidance here:
https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-on-behalf-of-flow

With this PR, do you think it would be possible to use FastMCP in proxy mode with authentication and then leverage the OBO flow for downstream MCP servers?

Also right now FastMCP support storing the auth session the redis store, do we think we could extend this to even for OBO token

One additional note: we ran into a small issue with refresh tokens when using OBO. Our understanding is that the MSAL library maintains an internal cache and automatically refreshes tokens, but in our case the token was not refreshed as expected.

Thanks a lot!

@JayDoubleu
Copy link
Copy Markdown

Great PR! The dependency injection pattern for OBO is really clean.

For anyone looking for a Managed Identity-only approach (no client_secret at all), I wrote a guide that uses:

  • RemoteAuthProvider + JWTVerifier instead of AzureProvider
  • Federated Identity Credentials to link MI to the App Registration
  • Custom OBOTokenService that uses MI token as client assertion

This works well for Azure Container Apps deployments where you want zero secrets.

Guide: https://jaydoubleu.dev/guides/mcp-azure-oauth2-obo

The approaches are complementary - this PR is great for scenarios where you have a client_secret, while the MI approach avoids secrets entirely.

@pamelafox
Copy link
Copy Markdown

I think it'd be fantastic for MCP AzureProvider to support the Federated Identity Credentials, since then developers could have the best of both worlds. Last I looked, the underlying OAuthProvider class would need to be more flexible to allow for use of managed identity, it currently assumes everyone is using a secret-based approach.

I think that could be done in a separate PR, however, as that aspect is orthogonal to the OBO functionality, from my perspective? Or @JayDoubleu do you think it'd require changes to this?

(Disclosure: I'm from Microsoft)

@pamelafox
Copy link
Copy Markdown

@mvgadagi Were you seeing a token expiry error? Are you using TokenCache() for the token_cache setting? After how much time did the token refresh issue happen? Was that with a deployed server in production? It'd be good to test this setup doesnt run into that issue. I haven't seen it yet with my similar OBO demo (https://github.com/Azure-Samples/python-mcp-demos/blob/main/servers/auth_entra_mcp.py#L110), but haven't battle tested in production.

@JayDoubleu
Copy link
Copy Markdown

I think that could be done in a separate PR, however, as that aspect is orthogonal to the OBO functionality, from my perspective? Or @JayDoubleu do you think it'd require changes to this?

You're right. AzureProvider can't be used for MI because it requires client_secret for OAuth proxy functionality. Perhaps another PR addressing it would be fine, although there might be extra rework required to make Azure happy. I'm currently rewriting my accelerator against FastMCP==2.14.4 with this and other workarounds mentioned in #3002. Might contribute it if no one else beats me to it.

@mvgadagi
Copy link
Copy Markdown

@mvgadagi Were you seeing a token expiry error? Are you using TokenCache() for the token_cache setting? After how much time did the token refresh issue happen? Was that with a deployed server in production? It'd be good to test this setup doesnt run into that issue. I haven't seen it yet with my similar OBO demo (https://github.com/Azure-Samples/python-mcp-demos/blob/main/servers/auth_entra_mcp.py#L110), but haven't battle tested in production.

Thank you @pamelafox for the response
/ After exactly an hour we faced the issued (As we have v2.0 token and they have have expiration set to 1 hour after they issued)
/ We had used the token_cache in logic already
/ It's not in production yet, but planning in by end of Q1 after testing the whole flow

And I will go through the reference you given and try to related to our implementation.

Thanks a lot !!

@JonasKs
Copy link
Copy Markdown
Contributor

JonasKs commented Jan 27, 2026

Hello everyone! I've taken some time to assess the AzureProvider again.

First of all, great writeup @pamelafox! There are some details I want to address, and I hope it's okay to do it here, as I think I've uncovered a bug or two.

From your blog:

However, since we are configuring this server to work with arbitrary MCP clients, we don't have that option

This is not true. This is auth code flow under the hood, and the FastMCP server is responsible for constructing the redirect URI. In other words, we can send something like this:

azure = AzureProvider(
    client_id=settings.MCP_SERVER_CLIENT_ID,
    client_secret=settings.MCP_SERVER_CLIENT_SECRET.get_secret_value(),
    tenant_id=settings.TENANT_ID,
    required_scopes=[f"user_impersonation"],
    additional_authorize_scopes=[
         "openid", 
         "profile", 
         "offline_access", 
         "api://<appID1>/user_impersonation",  # <-- request scope for multiple clients
         "api://<appID2>/user_impersonation"   # <-- these are NOT admin consented
    ],
    base_url="http://localhost:8000",
)

and it would correctly prompt us for consent for appID1 and appID2.
However, this triggers a bug:

image

I haven't really looked into a beautiful fix here, but something like this should work:

        ... code
            # Advertise full scopes including OIDC (even though we only validate non-OIDC)
            valid_scopes=parsed_required_scopes,
        )

        token_exchange_scopes = self._prefix_scopes_for_azure(
            parsed_required_scopes or []
        )
        # Only add OIDC scopes from additional_authorize_scopes, not other API scopes
        if parsed_additional_scopes:
            oidc_scopes = [s for s in parsed_additional_scopes if s in OIDC_SCOPES]
            token_exchange_scopes.extend(oidc_scopes)
        # Deduplicate while preserving order
        token_exchange_scopes = list(dict.fromkeys(token_exchange_scopes))
        self._extra_token_params = {"scope": " ".join(token_exchange_scopes)}
        logger.debug("Token exchange scopes: %s", token_exchange_scopes)

on this line.

This basically gets the prefixed required_scope + OIDC scopes and set them as an self._extra_token_params.
With this fix, the consent flow has been done, and the token fetched is correct.
We can then fetch tokens for multiple APIs, using this token:

@mcp.tool(name="change_state", description="Use this tool to change the state")
async def change_state(ctx: Context):
    # token = get_access_token() 
    token1 = OnBehalfOfCredential(...).get_token(scope=api://<appID1>/user_impersonation)
    token2 = OnBehalfOfCredential(...).get_token(scope=api://<appID1>/user_impersonation)
    print(f"{token1=}")
    print(f"{token2=}")
    ...

As for this PR — I really like this! OBO is a very common use-case for Azure, but I find it really nice for MCPs in general — no reason to double-implement authorization — just use the upstream token.
EDIT(Sorry, I clicked comment a bit too quick)
But from azure.identity import OnBehalfOfCredential exist. The nice thing about azure.identity (I believe opposed to msal) is that it's a higher level library which also deals with token caching, refreshing etc.
A common pattern is to create the obo = OnBehalfOfCredential() on startup, and then only use obo.get_token(scope=scope) in your code. If the token is expired, the get_token() would automatically refresh it.
So, I would highly recommend not re-implementing these, but rather re-export them.

@pamelafox
Copy link
Copy Markdown

pamelafox commented Jan 27, 2026

@JonasKs In that code, you have appID1, so wouldn't that be the case of a pre-known client ID? Or is app ID referring to something else? I think I'm a bit confused.

@JonasKs
Copy link
Copy Markdown
Contributor

JonasKs commented Jan 27, 2026

@pamelafox AppId1 and 2 are other appregs, yes. But they are not admin consented! ☺️

@pamelafox
Copy link
Copy Markdown

@JonasKs Ah, yes, for known client apps, the admin consent is not needed. I do not know how often that will be the case, but it's a good idea to make that clear in documentation.

@JonasKs
Copy link
Copy Markdown
Contributor

JonasKs commented Jan 27, 2026

What do you mean with "known"? Example of an unknown app?

@JonasKs
Copy link
Copy Markdown
Contributor

JonasKs commented Jan 28, 2026

I've raised a PR for the bug discovered above: #3013

@mvgadagi
Copy link
Copy Markdown

mvgadagi commented Jan 31, 2026

I've raised a PR for the bug discovered above: #3013

@JonasKs @jlowin I too confirm the issue and tested with fastmcp==3.0.0b1, adding extra scopes fails with:

Token exchange with identity provider failed: invalid_request: AADSTS28003: Provided value for the input parameter scope cannot be empty when requesting an access token using the provided authorization code. Please specify a valid scope.

additional_authorize_scopes=[
"api://app-registration-client-id-1/default",
"api://app-registration-client-id-2/default"
]

Probably the #3013 the fix is not released yet

@JonasKs
Copy link
Copy Markdown
Contributor

JonasKs commented Jan 31, 2026

Correct. Install from the main branch and try again.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/fastmcp/server/auth/providers/azure.py (1)

408-416: Remove unused noqa directive.

The # noqa: F401 comment is unnecessary here per Ruff. The import is intentionally for availability checking, and the linter doesn't flag it.

🧹 Suggested fix
 def _require_azure_identity(feature: str) -> None:
     """Raise ImportError with install instructions if azure-identity is not available."""
     try:
-        import azure.identity  # noqa: F401
+        import azure.identity
     except ImportError as e:
         raise ImportError(
             f"{feature} requires the `azure` extra. "
             "Install with: pip install 'fastmcp[azure]'"
         ) from e

Comment thread src/fastmcp/server/auth/providers/azure.py
@jlowin
Copy link
Copy Markdown
Member Author

jlowin commented Feb 2, 2026

Updated this PR to use azure.identity.aio.OnBehalfOfCredential instead of raw MSAL, based on @JonasKs's feedback. The EntraOBOToken dependency API is unchanged — the internals are just simpler now (async-native, no thread pool hack, automatic token caching/refresh). Dropped MSALApp since it was MSAL-specific; for advanced scenarios you can use CurrentAccessToken() and construct your own credential directly. Would love any thoughts.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/fastmcp/server/auth/providers/azure.py (1)

461-483: Reuse the shared azure-identity guard to keep error messaging consistent.

The create_obo_credential method duplicates the import-guard logic from _require_azure_identity. Call _require_azure_identity("OBO token exchange") before importing OnBehalfOfCredential to align with existing patterns in the codebase and maintain consistent error messaging across all OBO helpers.

♻️ Suggested refactor
-        try:
-            from azure.identity.aio import OnBehalfOfCredential
-        except ImportError as e:
-            raise ImportError(
-                "azure-identity is required for OBO token exchange. "
-                "Install with: pip install 'fastmcp[azure]'"
-            ) from e
+        _require_azure_identity("OBO token exchange")
+        from azure.identity.aio import OnBehalfOfCredential

Comment thread docs/integrations/azure.mdx
Comment thread docs/integrations/azure.mdx
- Add CurrentAccessToken and TokenClaim dependencies for extracting
  access tokens and claims in tool/resource/prompt handlers
- Add EntraOBOToken and MSALApp dependencies for Azure On-Behalf-Of
  token exchange flows
- Add get_msal_app() method to AzureProvider for MSAL client access
- Add fastmcp[azure] extra with msal dependency
- Split context.mdx into context.mdx and dependencies.mdx for clarity
- Use function pattern for MSALApp() consistent with other dependencies
- Add TokenClaim to dependency-injection docs and exports
- Remove duplicate CurrentAccessToken singleton (use function from main)
- Fix test to use CurrentAccessToken() with parens
@jlowin jlowin force-pushed the azure-obo-dependencies branch from 34785ae to ee8b1f5 Compare February 10, 2026 00:58
@jlowin jlowin merged commit 45af482 into main Feb 10, 2026
13 checks passed
@jlowin jlowin deleted the azure-obo-dependencies branch February 10, 2026 01:06
@sandeepkunusoth
Copy link
Copy Markdown

sandeepkunusoth commented Mar 5, 2026

Does OBO flow work on tools created using FastMCP.from_openapi( directly? i am getting invalid_token issue when used with openapi spec tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auth Related to authentication (Bearer, JWT, OAuth, WorkOS) for client or server. enhancement Improvement to existing functionality. For issues and smaller PR improvements. server Related to FastMCP server implementation or server-side functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants