fastmcp-obo
On-behalf-of token exchange for FastMCP servers.
A user signs in with Microsoft Entra ID. An MCP client calls your server with an access token whose audience is your MCP app. FastMCP checks that token. This library exchanges it for a second access token whose audience is a downstream API, using the user's delegated permissions.
Use it when each tool would otherwise repeat the same exchange, cache, and error handling. Configure the confidential client once. Tool functions keep the business logic.
This package is a general FastMCP helper. It is not tied to a particular MCP client.
The two exchanges
User
│ signs in to Microsoft Entra ID
▼
MCP client
│ exchange 1: the user's token → an access token for this MCP
│ Authorization: Bearer <token audience = api://MCP_APP_ID>
▼
FastMCP
│ AzureJWTVerifier checks signature, issuer, audience, expiry, and scopes
▼
fastmcp-obo
│ exchange 2: that MCP token → an access token for the downstream API
▼
Downstream API
FastMCP already knows how to validate an inbound JWT. This library does not replace that check and does not implement JWT cryptography. It also does not perform exchange 1. By the time a tool runs, the caller has already presented a token for this server.
ATLAS is one client that can perform exchange 1. The sample in examples/atlas_mcp.json tells ATLAS to request a delegated token for the MCP app. The library then performs exchange 2 inside the server. Any other MCP client that can present an Entra access token for your app works the same way.
FastMCP's own AzureProvider and EntraOBOToken are a different deployment. They apply when this process is the OAuth proxy that signed the user in. They require fastmcp[azure] and an AzureProvider auth backend. A resource server that only verifies a token from an external client, which is the usual ATLAS layout, uses AzureJWTVerifier plus this library.
Install
pip install fastmcp-obo
Requires Python 3.10 or newer. Runtime dependencies are FastMCP 4.1 or newer (below 5), MSAL, and httpx2.
Configure once
Explicit arguments do not read the environment:
from fastmcp_obo import EntraOBO
obo = EntraOBO(
tenant_id="00000000-0000-0000-0000-000000000000",
client_id="11111111-1111-1111-1111-111111111111",
client_secret="...",
scopes=["api://downstream-api/user_impersonation"],
)
Or load the names in Environment variables:
obo = EntraOBO.from_env()
Share one EntraOBO across tools. It holds the confidential-client credential and an in-memory token cache.
Validate inbound tokens with FastMCP. The short scope name is what Entra puts in the scp claim:
from fastmcp import FastMCP
from fastmcp.server.auth.providers.azure import AzureJWTVerifier
mcp = FastMCP(
"My MCP",
auth=AzureJWTVerifier(
client_id=obo.client_id,
tenant_id=obo.tenant_id,
required_scopes=["access_as_user"],
),
)
For Azure Government, pass base_authority="login.microsoftonline.us" to AzureJWTVerifier and authority_host="login.microsoftonline.us" to EntraOBO.
Decorator
Place @with_obo_token below @mcp.tool. FastMCP registers the wrapper. The wrapper keeps the original name, docstring, and parameters, so the token is not an argument in the MCP schema.
from fastmcp_obo import get_obo_token, with_obo_token
@mcp.tool
@with_obo_token(obo)
async def get_data(city: str) -> str:
"""Look up a city for the signed-in user."""
token = get_obo_token()
return await call_api(city, token)
get_obo_token() works only inside that wrapper, including in a sync tool. Sync tools run in a worker thread that receives a copy of the call context. The token is removed when the call returns or raises.
A full server is in examples/basic_decorator.py.
Dependency injection
FastMCP omits a parameter from the tool schema when its default is a Depends dependency. These helpers share the same exchange path as the decorator:
import httpx2
from fastmcp.dependencies import Depends
from fastmcp_obo import check_downstream_response
@mcp.tool
async def get_data(
api: httpx2.AsyncClient = obo.http_client("https://api.example.com"),
) -> dict[str, object]:
"""Fetch the signed-in user's data."""
response = await api.get("/data")
check_downstream_response(response)
return response.json()
@mcp.tool
async def token_length(token: str = Depends(obo)) -> int:
"""Return the length of the downstream access token."""
return len(token)
obo.token() is the same injection as Depends(obo). The HTTP client is created for the call and closed when the call ends, including on error. Redirects are off unless you pass follow_redirects=True. The base URL must be https, or http on localhost. A full server is in examples/dependency_injection.py.
Prefer dependency injection when the tool starts a task that continues after the tool returns. A task created during a decorated call inherits the context token and keeps its own copy after the decorator resets the parent.
Entra app registrations
You need two applications in the same tenant. On-behalf-of consent is granted ahead of time. The MCP server has no browser in which to prompt the user.
MCP server app (the confidential client this library uses):
- Register an application. Copy the application (client) id and the directory (tenant) id.
- Under Certificates & secrets, create a client secret, or upload a certificate. This library accepts either. Use one of them.
- Under Expose an API, set the Application ID URI to
api://{client-id}. - Add a delegated scope, for example
access_as_user. Admins and users can consent. - If a specific client should request that scope without a consent prompt, add it under Authorized client applications.
Downstream API app:
- Expose a delegated scope, for example
user_impersonation. The scope value is oftenapi://downstream-api/user_impersonation. Microsoft Graph uses fully qualified scopes such ashttps://graph.microsoft.com/Mail.Read. - On the MCP server app, open API permissions, add that delegated permission, and grant admin consent. A delegated OBO call fails until consent exists for the middle-tier app.
MCP client app (ATLAS or another client that performs exchange 1):
- Give it permission to request
api://{mcp-client-id}/access_as_user. - Point it at your server with a delegated token for that audience. The ATLAS shape is below.
The tenant id must be one directory. common, organizations, and consumers are rejected. On-behalf-of against those authorities does not identify a single token endpoint for the user's home tenant.
Environment variables
EntraOBO.from_env() reads these names. Keyword arguments override them. A missing tenant, client id, scopes, or credential raises ConfigurationError.
| Variable | Required | Purpose |
|---|---|---|
FASTMCP_OBO_TENANT_ID |
yes | Directory tenant id. One tenant, not common. |
FASTMCP_OBO_CLIENT_ID |
yes | Application id of this MCP server. |
FASTMCP_OBO_SCOPES |
yes | Downstream delegated scopes, separated by commas or whitespace. |
FASTMCP_OBO_CLIENT_SECRET |
one credential | Client secret. |
FASTMCP_OBO_CERTIFICATE_PRIVATE_KEY_FILE |
one credential | PEM private key. Pair with the certificate file. |
FASTMCP_OBO_CERTIFICATE_FILE |
with the key | PEM public certificate. The private key must not be in this file. |
FASTMCP_OBO_CERTIFICATE_THUMBPRINT |
no | Hex thumbprint. Omit it to let MSAL use the certificate's SHA-256 thumbprint. |
FASTMCP_OBO_CERTIFICATE_PASSPHRASE |
no | Passphrase for an encrypted private key. |
FASTMCP_OBO_AUTHORITY_HOST |
no | Defaults to login.microsoftonline.com. |
FASTMCP_OBO_EXPECTED_AUDIENCE |
no | Audiences accepted on the inbound token. Defaults to the client id and api://{client_id}. |
FASTMCP_OBO_REQUIRED_INBOUND_SCOPES |
no | Extra short scp names required before exchange. |
Set the client secret or the certificate variables, not both. See .env.example.
A host that already has a workload-identity assertion can pass client_credential={"client_assertion": callable} to the constructor. The callable is MSAL's hook. This library does not store that assertion.
Token validation and audience
Two different checks run, on purpose.
FastMCP verifies the bearer token's signature, issuer, expiry, and required scopes before the tool is called. AzureJWTVerifier uses:
- JWKS
https://{host}/{tenant}/discovery/v2.0/keys - issuer
https://{host}/{tenant}/v2.0for a specific tenant - audience
{client_id}andapi://{client_id}(or your Application ID URI)
This library then refuses to forward a credential that is unsafe as an OBO assertion, even if a verifier was misconfigured:
- The assertion must be a compact signed JWT (
algother thannone, typeJWTorat+jwtwhen present). Encrypted JWTs and opaque strings are rejected. audmust intersect the configured audiences. The default is this MCP app, so a token minted for the downstream API is not forwarded again.tidmust equal the configured tenant.oidis required. The cache identity is{oid}.{tid}, which is the Entra object id plus tenant. An email address is not an identity key.scpmust be present and non-empty. An ID token (anonceclaim, noscp) is rejected. An app-only token (idtypofapp) is rejected.expmust still be valid, with 60 seconds of clock skew.- When FastMCP supplies claims,
aud,oid,tid, andscpmust agree with the JWT payload. A mismatch means the verified token and the assertion are not the same credential.
required_inbound_scopes uses the short names from scp, such as access_as_user, not the api:// form.
Errors
Messages are safe to return to an MCP client. They do not include tokens, secrets, assertions, or the identity provider's error_description. Server logs get a short code on the exception, such as invalid_grant:50013 or insufficient_scope.
| Exception | Meaning |
|---|---|
AuthenticationRequired |
This call has no inbound access token, or get_obo_token() ran outside the decorator. |
InsufficientScope |
The inbound token is missing a scope this library required. Subclass of AuthenticationRequired. |
TokenExchangeError |
The assertion is unusable, or Entra refused it. Retrying the same assertion will fail. Includes expired and revoked credentials. |
ConfigurationError |
Tenant, client, secret, certificate, or downstream scope configuration was rejected. An operator has to change it. |
IdentityProviderUnavailable |
Network failure or a temporary Entra error (server_error, temporarily_unavailable). A later attempt may work. The default is two attempts. |
DownstreamError |
The downstream HTTP call returned status 400 or higher. The message contains the status code only. |
Troubleshooting
Authentication is required.
The HTTP request did not pass FastMCP's verifier, or the tool called get_obo_token() without @with_obo_token. In-process tests must install an access token the same way the HTTP stack does. The decorator and the dependencies both read fastmcp.server.dependencies.get_access_token().
The inbound credential cannot be exchanged.
The bearer token is not a delegated access token for this app. Check the log code. Typical causes are the wrong audience, an ID token, an app-only token, a tenant mismatch, or expiry. Confirm the client requested api://{mcp-client-id}/access_as_user and that FastMCP's audience list matches expected_audiences.
The inbound token does not include the required scopes.
required_inbound_scopes names a short scope that is absent from scp.
Token exchange is misconfigured. or an invalid-client configuration error.
The client id, secret, certificate, or tenant does not match the app registration. Entra errors invalid_client and unauthorized_client, and secret or assertion failures such as AADSTS 7000215, are reported as configuration errors and are not retried.
The identity provider is temporarily unavailable.
Entra or the network failed. The call is retried once by default. Check outbound access to https://login.microsoftonline.com/{tenant}.
The downstream service returned HTTP 401.
Exchange succeeded and the API rejected the token. The usual cause is a scope the downstream app did not grant to the MCP app, or admin consent still pending.
The tool schema contains token or api.
@with_obo_token is above @mcp.tool, or the parameter default is an ordinary value. Put the tool decorator on the outside. For injection, use Depends(obo), obo.token(), or obo.http_client(url).
Security
- Fail closed. Missing authentication, a bad assertion, or a claims mismatch produces an error and no downstream call.
- Access tokens, refresh tokens, client secrets, and raw assertions are not written to this library's logs or exception messages. Keep the
msallogger at INFO or higher. MSAL's own DEBUG logs can include provider error text. - The default token cache is in memory and dies with the process. Pass your own MSAL cache only when you understand where it is stored. This library does not write a cache file.
- Cache lookup uses the object id and tenant from the inbound token, then MSAL's account record (
home_account_id), together with the client id and scopes. Two users do not share an entry. A cached token whose refresh is refused is dropped and a new exchange is attempted with the current assertion. - The authorized HTTP client will not send its bearer token to an
httpURL except localhost, and it will not follow redirects unless you opt in. check_downstream_responsedrops the response body so an API that echoes the bearer token does not put it on an MCP error.- Do not return
get_obo_token()to the MCP client.
Limitations of this version:
- Microsoft Entra on-behalf-of only. The
TokenExchangerprotocol is the extension point for a later provider, including RFC 8693. No other provider is included. - One tenant per
EntraOBOinstance. - The library checks claims. It does not verify the JWT signature. Configure FastMCP's verifier.
- The in-memory cache grows with the number of users. It is not a distributed cache.
- MSAL instance discovery is disabled. Tokens are requested from the configured authority host only.
- MSAL throttles some HTTP 429 and HTTP 5xx responses with a key that does not include the user assertion. For a few seconds, one user's outage response can be replayed for another user of the same client. The replay is an error, not an access token. Successful token responses are not shared that way.
- There is no built-in managed-identity client. A host can supply
client_credential={"client_assertion": callable}.
ATLAS client entry
examples/atlas_mcp.json is client configuration, not library configuration. Replace the host and MCP_APP_ID.
{
"my-mcp": {
"enabled": true,
"url": "https://example.gov/mcp",
"transport": "http",
"auth_type": "delegated",
"delegation": {
"audience": "api://MCP_APP_ID",
"scope": "api://MCP_APP_ID/access_as_user"
}
}
}
audience and scope are the MCP app from exchange 1. The downstream scope passed to EntraOBO is a different value, from exchange 2.
Public API
| Symbol | Role |
|---|---|
EntraOBO |
Confidential client and cache. from_env(), exchange(), acquire_for_current_request(), token(), http_client(). |
with_obo_token |
Decorate a tool and store the downstream token for that call. |
get_obo_token |
Read the token stored by the decorator. |
TokenExchanger |
Protocol implemented by EntraOBO. |
check_downstream_response |
Raise DownstreamError on HTTP 400 or higher, without copying the body. |
OBOError |
Base exception. Subclasses: ConfigurationError, AuthenticationRequired, InsufficientScope, TokenExchangeError, IdentityProviderUnavailable, DownstreamError. |
Development
uv sync --group dev
uv run ruff check .
uv run ruff format --check .
uv run pyright
uv run pytest
uv build
Tests mock Entra. They do not need a tenant or network access. Publishing steps, including PyPI Trusted Publishing, are in PUBLISHING.md.
License
Apache-2.0. See LICENSE.
Metadata
Release files for fastmcp-obo 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| fastmcp_obo-0.1.0.tar.gz | 36.1 kB | With provenance | Details for fastmcp_obo-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| fastmcp_obo-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for fastmcp_obo-0.1.0-py3-none-any.whl |
Total release size: 65.4 kB
Release files / fastmcp_obo-0.1.0.tar.gz
| Download URL | fastmcp_obo-0.1.0.tar.gz |
|---|---|
| Size | 36.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ccb4398a06906d9f118636210a39f42f99465556600a2aef2b0451e1d4fcaa4b
|
|
BLAKE2b-256 checksum How to use checksums |
e57f984dfaacaa911da773a3fbb52c258eeff4bf7bc1309ea8f7e41b22938ffd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency logRelease files / fastmcp_obo-0.1.0-py3-none-any.whl
| Download URL | fastmcp_obo-0.1.0-py3-none-any.whl |
|---|---|
| Size | 29.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8ee810f3531660499a8ce17cb977db63c0f5c71d98c4fe1ef6546bb322d92102
|
|
BLAKE2b-256 checksum How to use checksums |
5f9c117610ea99a7fd546fad5858b55c51c36e7449b27ac3542dd6dd0d01317b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency logSecurity for fastmcp-obo 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for fastmcp_obo-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.