Any remote server touching real accounts needs per-user access control that every MCP client understands.
Local stdio servers get credentials from their environment. Remote servers use the spec's authorization flow, built on OAuth 2.1, the standard behind "Sign in with..." buttons. In OAuth the user signs in at an authorization server, which issues an access token: a short-lived key, limited to certain scopes (permissions like notes:read), that the client sends with each request.
In MCP terms the MCP server is a resource server (it checks tokens), the MCP client is the OAuth client, and the authorization server can be your identity provider (Auth0, Okta, Entra ID, Keycloak and so on).
The flow:
1. The client calls the server without a token and gets 401 Unauthorized, with a header pointing to the server's Protected Resource Metadata (a JSON document at a /.well-known/ URL). 2. That metadata names the authorization server(s); the client fetches their metadata to find the login endpoints. 3. The client identifies itself, preferably with a Client ID Metadata Document (a URL describing the app). Dynamic Client Registration still works but is deprecated. 4. The user logs in and approves in the browser (authorization code flow with PKCE, which protects the code from interception). The client checks the returned iss (issuer) value before using the code. 5. The client sends Authorization: Bearer <token> on every request. The token must be issued for this server (the resource indicator).
On the server with the Python SDK, pass a TokenVerifier and AuthSettings; the SDK serves the metadata, rejects bad tokens and exposes the caller's identity. Request only the scopes you need; ask for more later (step-up) when a tool needs them.
Example
from pydantic import AnyHttpUrl
from mcp.server import MCPServer
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.settings import AuthSettings
RESOURCE = "https://notes.example.com/mcp"
class MyVerifier(TokenVerifier):
async def verify_token(self, token: str) -> AccessToken | None:
# Real code: validate signature, expiry, audience == RESOURCE (or call introspection)
if token == "demo-token":
return AccessToken(token=token, client_id="demo", scopes=["notes:read"], resource=RESOURCE)
return None
mcp = MCPServer(
"Notes",
token_verifier=MyVerifier(),
auth=AuthSettings(
issuer_url=AnyHttpUrl("https://auth.example.com"), # your authorization server
resource_server_url=AnyHttpUrl(RESOURCE),
required_scopes=["notes:read"],
validate_token_resource=True, # reject tokens issued for other servers
),
)Remember
- Remote MCP auth = OAuth 2.1; the MCP server is a resource server
- Clients discover the auth server via Protected Resource Metadata after a 401
- Tokens must be scoped and issued for this specific server
- Client ID Metadata Documents are preferred; Dynamic Client Registration is deprecated
Watch out for
- Accepting any valid token from your identity provider without checking it was issued for this MCP server (its audience/resource).