fastmcp/docs/integrations/atproto.mdx
zzstoatzz 275a5e2d56 auth: build ATProtoProvider on atproto-oauth and OAuthProxy callback hooks
The provider no longer carries its own AT Protocol client: the new
`atproto` extra installs atproto-oauth, which handles resolution, PAR,
DPoP, token exchange, re-verification and revocation. OAuthProxy's
upstream callback now calls two overridable steps, the callback's
transaction ID and the code exchange, so the provider plugs into them
instead of replacing the callback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uN3zXKrzsKxYKNmkNK9Dz
2026-09-26 21:18:25 -05:00

83 lines
3.9 KiB
Text

---
title: AT Protocol OAuth 🤝 FastMCP
sidebarTitle: AT Protocol
description: Let people sign in to your FastMCP server with their AT Protocol handle
icon: at
---
import { VersionBadge } from "/snippets/version-badge.mdx"
<VersionBadge version="4.1.0" />
<Warning>
`ATProtoProvider` is experimental and lives in `fastmcp.experimental`. Its interface may change or be removed in any release, so pin your FastMCP version when you use it.
</Warning>
`ATProtoProvider` lets people sign in to your FastMCP server with their [AT Protocol](https://atproto.com) account, wherever it is hosted. You don't register an app anywhere: AT Protocol clients identify themselves by a URL they serve, and FastMCP serves it for you.
MCP clients see a standard OAuth server with dynamic client registration, inherited from the [OAuth Proxy](/servers/auth/oauth-proxy). After the consent screen, users enter their handle or DID and approve the sign-in with their account's authorization server. FastMCP keeps only the verified DID, revokes the grant right away, and issues its own tokens. The AT Protocol side is handled by [`atproto-oauth`](https://pypi.org/project/atproto-oauth/), installed with the `atproto` extra:
```bash
pip install "fastmcp[atproto]"
```
## Configuration
```python server.py
from fastmcp import FastMCP
from fastmcp.experimental.auth.atproto import ATProtoProvider
auth = ATProtoProvider(
base_url="https://mcp.example.com",
jwt_signing_key="a-long-random-secret",
allowed_dids=["did:plc:ewvi7nxzyoun6zhxrhs64oiz"],
)
mcp = FastMCP("My Server", auth=auth)
```
- `base_url` is the public URL of your server. FastMCP serves the AT Protocol client metadata at `{base_url}/oauth-client-metadata.json` and receives the sign-in at `{base_url}/auth/callback`.
- `jwt_signing_key` signs the tokens FastMCP issues. Keep it stable, or every session ends when the server restarts.
- `allowed_dids` lists the accounts that may sign in. Leave it out to allow any account. Use DIDs rather than handles, because a handle can move to a different account. To find an account's DID, look up the `_atproto` TXT record of its handle (for example, `dig TXT _atproto.alice.example.com`) or fetch `https://<handle>/.well-known/atproto-did`.
The allowlist is checked before the user is sent to their authorization server, again when they return, and on every request. Removing a DID takes effect on that account's next request.
With exactly one allowed DID, there is no handle to ask for: after the consent screen, the browser goes straight to that account's authorization server. The handle page appears only to show an error.
## Local development
AT Protocol treats `http://127.0.0.1` as a loopback client that needs no hosted metadata, so local development works without a tunnel:
```python
auth = ATProtoProvider(
base_url="http://127.0.0.1:8000",
jwt_signing_key="dev-secret",
)
```
Use `127.0.0.1` rather than `localhost`: authorization servers reject `localhost` redirect URIs, so the provider refuses it.
## Reading the signed-in account
The access token carries the account's DID as its subject and its handle as a claim:
```python
from fastmcp.server.dependencies import get_access_token
@mcp.tool
def whoami() -> str:
token = get_access_token()
return f"{token.claims['handle']} ({token.subject})"
```
The `handle` claim is set only when the account's handle resolves back to its DID; otherwise it is `None`.
## Sessions
`identity_token_expiry_seconds` (default one hour) sets how long each access token lasts, and `session_expiry_seconds` (default 30 days) sets how long a sign-in lasts before the user must sign in again. Refreshing re-checks the allowlist but doesn't contact the account's authorization server.
## Login page
The login page suggests accounts as the user types, using `typeahead_url`. Set it to `None` to turn suggestions off.
Pending sign-ins are kept in memory, so run a single server process per provider.