Skip to content

Authorization

Over Streamable HTTP your MCP server is an ordinary web service, and you protect it the way you protect any web service: with OAuth 2.1 bearer tokens.

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:

server.py
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 = "http://127.0.0.1:8000/mcp"

KNOWN_TOKENS = {
    "alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"], resource=RESOURCE),
}


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(RESOURCE),
        required_scopes=["notes:read"],
        validate_token_resource=True,
    ),
)


@mcp.tool()
def list_notes() -> list[str]:
    """List every note in the notebook."""
    return ["Buy milk", "Ship the release"]
  • TokenVerifier is a protocol with one async method. verify_token gets the raw token from the Authorization header and returns an AccessToken if it's valid, None if it isn't. There is nothing else to implement.
  • This one looks the token up in a table; each entry records the resource it was issued for. A real one verifies a JWT signature or calls the authorization server's token-introspection endpoint, and reports who the token was issued for (its aud) in AccessToken.resource. That code is yours; the SDK only calls it.
  • token_verifier= and auth= always travel together. Pass one without the other and MCPServer(...) raises a ValueError before it ever serves a request.

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.
  • validate_token_resource: refuse any token whose AccessToken.resource is not resource_server_url. Leaving it unset while resource_server_url is set warns (MCPDeprecationWarning) and behaves as False; 3.0 makes True the default for resource servers.
  • Turn it on when your authorization server binds tokens to the resource the client requested, which MCP clients always send. Keep resource_server_url the exact URL clients connect to.
  • Leave it off when your authorization server uses its own audience identifiers (an Auth0 API identifier, an Entra application ID) and check aud in your verifier instead, returning None for a token that isn't for this server.
  • If aud is a list, put the entry that equals resource_server_url in resource.

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

The SDK's built-in OAuth integration uses HTTP headers, so it applies only to 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:

server.py
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

RESOURCE = "http://127.0.0.1:8000/mcp"

KNOWN_TOKENS = {
    "alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"], resource=RESOURCE),
}


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(RESOURCE),
        required_scopes=["notes:read"],
        validate_token_resource=True,
    ),
)


@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 extra claims you 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 over stdio it is always None.

Call whoami with Authorization: Bearer alice-token and the model reads:

alice (scopes: notes:read)

Custom transport identities

server.py
import secrets
from dataclasses import dataclass

from mcp.server import ServerRequestContext
from mcp.server.mcpserver import MCPServer
from mcp.server.request_state import RequestStateSecurity
from mcp.shared.transport import TransportContext


@dataclass(kw_only=True, frozen=True)
class VerifiedPeer(TransportContext):
    principal: str


def principal(ctx: ServerRequestContext) -> str:
    if not isinstance(ctx.transport, VerifiedPeer):
        raise ValueError("Verified transport identity is required")
    return ctx.transport.principal


mcp = MCPServer(
    "broker-service",
    request_state_security=RequestStateSecurity(
        keys=[secrets.token_bytes(32)],
        bind_principal=principal,
    ),
)

Have your adapter attach VerifiedPeer only after authenticating the caller. runtime.connect(transport_builder=...) passes that metadata to handlers as ctx.transport. Use a stable, namespaced principal that distinguishes the issuing authority and user, not a display name or a client-supplied _meta field.

The existing RequestStateSecurity.bind_principal hook binds sealed request state to this identity. Another principal cannot replay it. Raising when verified metadata is absent prevents state from silently becoming anonymous. This hook protects multi-round-trip state; it does not authenticate connections or authorize ordinary tool calls. Those checks still belong at the adapter boundary and in your application policy.

The generated key suits a single process. Share keys across workers when retries can reach another instance, as described in Protecting request state.

Broker credentials are not publisher identity

A service's broker credentials authenticate the service, not every publisher. Bind peers through broker-enforced topic or queue permissions, or verify an end-user credential yourself. Validate reply destinations before sending data. The SDK's get_access_token() remains an HTTP OAuth helper; custom transport metadata does not populate it automatically.

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.

Recap

  • Over Streamable HTTP your server is an OAuth 2.1 resource server: it verifies tokens, it never issues them.
  • TokenVerifier is the whole integration surface: one async method, token in, AccessToken | None out.
  • token_verifier= and auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...]) always travel together.
  • The SDK publishes RFC 9728 Protected Resource Metadata at /.well-known/oauth-protected-resource/... and answers unauthenticated requests with a 401 whose WWW-Authenticate header points at it. That is the entire discovery story.
  • get_access_token() in any handler is who's calling.
  • Authorization is an HTTP concern. stdio and the in-memory test 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.