Metadata-Version: 2.5
Name: fastmcp-obo
Version: 0.1.0
Summary: On-behalf-of token exchange for FastMCP servers using Microsoft Entra ID
Project-URL: Homepage, https://github.com/garland3/fastmcp-obo
Project-URL: Repository, https://github.com/garland3/fastmcp-obo
Project-URL: Changelog, https://github.com/garland3/fastmcp-obo/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/garland3/fastmcp-obo/issues
Author: garland3
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: entra,fastmcp,mcp,microsoft,oauth,obo,on-behalf-of,token-exchange
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Security
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: fastmcp<5,>=4.1
Requires-Dist: httpx2>=2
Requires-Dist: msal>=1.35
Description-Content-Type: text/markdown

# fastmcp-obo

On-behalf-of token exchange for [FastMCP](https://github.com/jlowin/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

```text
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`](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

```bash
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:

```python
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](#environment-variables):

```python
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:

```python
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.

```python
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`](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:

```python
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`](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`](.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`](examples/atlas_mcp.json) is client configuration, not library configuration. Replace the host and `MCP_APP_ID`.

```json
{
  "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

```bash
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](PUBLISHING.md).

## License

Apache-2.0. See [LICENSE](LICENSE).
