--- 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" `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. `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:///.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.