mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-10-11 15:33:20 +02:00
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
83 lines
3.9 KiB
Text
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.
|