Authorization
Over Streamable HTTP your MCP server is an ordinary web service, and you protect it the way you protect any web service: with bearer tokens. Most of this page is the OAuth 2.1 shape, where an authorization server issues them; Just a pre-shared token at the end is the smaller case where you hand one out yourself.
In OAuth terms, your server is a resource server. It never signs anyone in and it never issues a token. It does one thing: look at the Authorization header on each request and decide whether the token in it is good.
This page is the server side. A client that discovers your authorization server and fetches the token is OAuth clients.
The three parties
- The authorization server signs people in and issues access tokens. You don't write this. It's your identity provider (Auth0, Keycloak, Entra, your own).
- The resource server is your MCP server. It verifies the token on every request.
- The client discovers which authorization server you trust, gets a token from it, and sends it back to you as
Authorization: Bearer <token>.
That's the whole triangle. Everything on this page is the middle bullet.
A token verifier
The SDK has no opinion about what a valid token looks like. You tell it, by implementing TokenVerifier:
from pydantic import AnyHttpUrl
from mcp.server import MCPServer
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.settings import AuthSettings
KNOWN_TOKENS = {
"alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"]),
}
class StaticTokenVerifier(TokenVerifier):
async def verify_token(self, token: str) -> AccessToken | None:
return KNOWN_TOKENS.get(token)
mcp = MCPServer(
"Notes",
token_verifier=StaticTokenVerifier(),
auth=AuthSettings(
issuer_url=AnyHttpUrl("https://auth.example.com"),
resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"),
required_scopes=["notes:read"],
),
)
@mcp.tool()
def list_notes() -> list[str]:
"""List every note in the notebook."""
return ["Buy milk", "Ship the release"]
TokenVerifieris a protocol with one async method.verify_tokengets the raw token from theAuthorizationheader and returns anAccessTokenif it's valid,Noneif it isn't. There is nothing else to implement.- This one looks the token up in a table. A real one verifies a JWT signature or calls the authorization server's token-introspection endpoint. That code is yours; the SDK only calls it.
token_verifier=is the gate.auth=is what the server publishes about that gate, plus the scopes it insists on, so it is meaningless alone: passauth=without a verifier andMCPServer(...)raises aValueErrorbefore it ever serves a request. The reverse, a verifier with noauth=, is legitimate and smaller: Just a pre-shared token.
AuthSettings is the public face of your resource server:
issuer_url: the authorization server that issues your tokens.resource_server_url: the public URL of this MCP endpoint. It names which resource a token is for, and it's where the discovery document lives.required_scopes: every token must carry all of them.
Tip
examples/servers/simple-auth/ in the SDK repository has an IntrospectionTokenVerifier that calls
a real authorization server's RFC 7662 endpoint. It's the shape most production verifiers take.
What you get over HTTP
Authorization lives in HTTP headers, so it exists only on the HTTP transports. Run it on the one you deploy: mcp.run(transport="streamable-http") puts it on http://127.0.0.1:8000/mcp, and Running your server has the rest. The app now has two routes:
/mcp
/.well-known/oauth-protected-resource/mcp
You registered one tool. The second route is the SDK's.
Discovery
GET that well-known path and you get RFC 9728 Protected Resource Metadata, built straight from your AuthSettings:
{
"resource": "http://127.0.0.1:8000/mcp",
"authorization_servers": ["https://auth.example.com/"],
"scopes_supported": ["notes:read"],
"bearer_methods_supported": ["header"]
}
This document is how a client that has never heard of your server finds its way in: it reads authorization_servers and goes there for a token. You wrote none of it.
Check
Call /mcp with no token (or with one your verifier returned None for) and the request is
stopped at the door:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"
{"error": "invalid_token", "error_description": "Authentication required"}
Nothing was parsed and no tool ran. And that resource_metadata pointer in WWW-Authenticate is
what makes discovery automatic: 401 -> metadata document -> authorization server -> token -> retry.
Warning
None of this protects stdio. A pipe has no Authorization header, so token_verifier is never
consulted there. A stdio server's security boundary is the process that launched it. The same
goes for the in-memory Client(mcp) you use in tests: it connects straight to the server object
and skips the HTTP layer, authorization included.
The caller's identity
Inside any handler, get_access_token() is the AccessToken your verifier returned for the current request:
from pydantic import AnyHttpUrl
from mcp.server import MCPServer
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.settings import AuthSettings
KNOWN_TOKENS = {
"alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"]),
}
class StaticTokenVerifier(TokenVerifier):
async def verify_token(self, token: str) -> AccessToken | None:
return KNOWN_TOKENS.get(token)
mcp = MCPServer(
"Notes",
token_verifier=StaticTokenVerifier(),
auth=AuthSettings(
issuer_url=AnyHttpUrl("https://auth.example.com"),
resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"),
required_scopes=["notes:read"],
),
)
@mcp.tool()
def whoami() -> str:
"""Report which OAuth client is calling."""
token = get_access_token()
if token is None:
return "anonymous"
return f"{token.client_id} (scopes: {', '.join(token.scopes)})"
- It works in tools, resources, and prompts, and there is nothing to pass around: the auth middleware stores it in a context variable per request.
- You get back the same object your verifier built:
client_id,scopes,subject,expires_at, and any extraclaimsyou attached. That's the hook for per-tool rules: read the scopes and refuse. - Outside an authenticated HTTP request it returns
None. In-memory and overstdioit is alwaysNone.
Call whoami with Authorization: Bearer alice-token and the model reads:
alice (scopes: notes:read)
The half the SDK doesn't do
The SDK gives you the resource-server half: verify, advertise, refuse. It does not give you a login page, a consent screen, or a token.
To watch all three parties move, run examples/servers/simple-auth/ from the SDK repository (a small authorization server and a resource server set up exactly like this page) and then point examples/clients/simple-auth-client/ at it for the full discovery-and-token dance.
Info
There is a second constructor argument, auth_server_provider=, that embeds a full authorization
server inside your MCP server. It predates the AS/RS separation that the MCP authorization spec
is built around. New servers should not reach for it.
An authorization server can also accept an enterprise identity provider's signed assertion in place of a user clicking through a consent screen, and the SDK supports both sides of that exchange. The grant, and the client that presents it, is Identity assertion.
Just a pre-shared token
Sometimes there is no authorization server anywhere: you minted a token yourself, handed it to the one client that needs it, and all the server has to do is check it. Keep the verifier and drop auth=:
import os
import secrets
from mcp.server import MCPServer
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken, TokenVerifier
API_TOKEN = os.environ.get("NOTES_API_TOKEN") or secrets.token_urlsafe(32)
class PresharedTokenVerifier(TokenVerifier):
async def verify_token(self, token: str) -> AccessToken | None:
if secrets.compare_digest(token.encode(), API_TOKEN.encode()):
return AccessToken(token=token, client_id="notes-client", scopes=[])
return None
mcp = MCPServer("Notes", token_verifier=PresharedTokenVerifier())
@mcp.tool()
def whoami() -> str:
"""Report which client is calling."""
token = get_access_token()
if token is None:
return "anonymous"
return token.client_id
- No
AuthSettingsmeans nothing is advertised. The app has the one/mcproute and no/.well-known/oauth-protected-resource/mcp, and the 401 loses itsresource_metadatapointer. The gate itself is the same, and so isget_access_token(). - With nothing to discover, the client must arrive already holding the token. For the python
Clientthat is anAuthorizationheader on thehttpx2.AsyncClientyou hand tostreamable_http_client(Client transports has it); for a host, it is wherever that host's server entry takes request headers, usually aheadersblock. An OAuth-capable client that turns up without the token gets the 401 and has nowhere to go from there. - A pre-shared token is a password. Compare it with
secrets.compare_digest, keep it in the environment and out of the source (unset, this server mints a random one at startup, so a missing variable locks the door rather than opening it), and put TLS in front of anything that is not localhost.
Check
Call /mcp with no token and the door is exactly as shut:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required"
{"error": "invalid_token", "error_description": "Authentication required"}
The same refusal as before, minus the resource_metadata that would have sent a client looking
for an authorization server you don't have.
Recap
- Over Streamable HTTP your server is an OAuth 2.1 resource server: it verifies tokens, it never issues them.
TokenVerifieris the whole integration surface: one async method, token in,AccessToken | Noneout.token_verifier=alone is a complete gate, and the right one for a token you hand out yourself. Addauth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])when a real authorization server issues the tokens.- With
AuthSettings, the SDK publishes RFC 9728 Protected Resource Metadata at/.well-known/oauth-protected-resource/...and answers unauthenticated requests with a 401 whoseWWW-Authenticateheader points at it. That is the entire discovery story. get_access_token()in any handler is who's calling.- Authorization is an HTTP concern.
stdioand the in-memory client never see it.
The client half (discovering your authorization server and fetching the token for you) is OAuth clients. And a client that asserts an identity instead of asking a user for one is Identity assertion.