Skip to main content

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):

  1. Register an application. Copy the application (client) id and the directory (tenant) id.
  2. Under Certificates & secrets, create a client secret, or upload a certificate. This library accepts either. Use one of them.
  3. Under Expose an API, set the Application ID URI to api://{client-id}.
  4. Add a delegated scope, for example access_as_user. Admins and users can consent.
  5. If a specific client should request that scope without a consent prompt, add it under Authorized client applications.

Downstream API app:

  1. Expose a delegated scope, for example user_impersonation. The scope value is often api://downstream-api/user_impersonation. Microsoft Graph uses fully qualified scopes such as https://graph.microsoft.com/Mail.Read.
  2. 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):

  1. Give it permission to request api://{mcp-client-id}/access_as_user.
  2. 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.0 for a specific tenant
  • audience {client_id} and api://{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 (alg other than none, type JWT or at+jwt when present). Encrypted JWTs and opaque strings are rejected.
  • aud must intersect the configured audiences. The default is this MCP app, so a token minted for the downstream API is not forwarded again.
  • tid must equal the configured tenant. oid is required. The cache identity is {oid}.{tid}, which is the Entra object id plus tenant. An email address is not an identity key.
  • scp must be present and non-empty. An ID token (a nonce claim, no scp) is rejected. An app-only token (idtyp of app) is rejected.
  • exp must still be valid, with 60 seconds of clock skew.
  • When FastMCP supplies claims, aud, oid, tid, and scp must 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 msal logger 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 http URL except localhost, and it will not follow redirects unless you opt in.
  • check_downstream_response drops 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 TokenExchanger protocol is the extension point for a later provider, including RFC 8693. No other provider is included.
  • One tenant per EntraOBO instance.
  • 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)

Source distribution for fastmcp-obo 0.1.0
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)

Table of built distributions (wheels) for fastmcp-obo 0.1.0
File Interpreter ABI Platform Actions
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 log

Release 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 log

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

Select from 2 files

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 log

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

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page