Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions docs/run/authorization.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 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.
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](#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.

Expand All @@ -24,7 +24,7 @@ The SDK has no opinion about what a valid token looks like. You tell it, by impl

* `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. 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=` and `auth=` always travel together. Pass one without the other and `MCPServer(...)` raises a `ValueError` before it ever serves a request.
* `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: pass `auth=` without a verifier and `MCPServer(...)` raises a `ValueError` before it ever serves a request. The reverse, a verifier with no `auth=`, is legitimate and smaller: **[Just a pre-shared token](#just-a-pre-shared-token)**.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: auth= without an explicit token_verifier is also valid when auth_server_provider= is supplied; MCPServer derives the verifier from that provider. Qualify this statement to say that auth= alone, without either a verifier or provider, raises.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/run/authorization.md, line 27:

<comment>`auth=` without an explicit `token_verifier` is also valid when `auth_server_provider=` is supplied; `MCPServer` derives the verifier from that provider. Qualify this statement to say that `auth=` alone, without either a verifier or provider, raises.</comment>

<file context>
@@ -24,7 +24,7 @@ The SDK has no opinion about what a valid token looks like. You tell it, by impl
 * `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. 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=` and `auth=` always travel together. Pass one without the other and `MCPServer(...)` raises a `ValueError` before it ever serves a request.
+* `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: pass `auth=` without a verifier and `MCPServer(...)` raises a `ValueError` before it ever serves a request. The reverse, a verifier with no `auth=`, is legitimate and smaller: **[Just a pre-shared token](#just-a-pre-shared-token)**.
 
 `AuthSettings` is the public face of your resource server:
</file context>
Suggested change
* `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: pass `auth=` without a verifier and `MCPServer(...)` raises a `ValueError` before it ever serves a request. The reverse, a verifier with no `auth=`, is legitimate and smaller: **[Just a pre-shared token](#just-a-pre-shared-token)**.
* `token_verifier=` is the gate. `auth=` is what the server *publishes* about that gate, plus the scopes it insists on, so it needs either a verifier or an `auth_server_provider=`: pass `auth=` without either and `MCPServer(...)` raises a `ValueError` before it ever serves a request. The reverse, a verifier with no `auth=`, is legitimate and smaller: **[Just a pre-shared token](#just-a-pre-shared-token)**.


`AuthSettings` is the public face of your resource server:

Expand Down Expand Up @@ -113,12 +113,37 @@ To watch all three parties move, run `examples/servers/simple-auth/` from the SD

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](../client/identity-assertion.md)**.

## 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=`:

```python title="server.py" hl_lines="8 13-15 18"
--8<-- "docs_src/authorization/tutorial003.py"
```

* No `AuthSettings` means nothing is advertised. The app has the one `/mcp` route and no `/.well-known/oauth-protected-resource/mcp`, and the 401 loses its `resource_metadata` pointer. The gate itself is the same, and so is `get_access_token()`.
* With nothing to discover, the client must arrive already holding the token. For the python `Client` that is an `Authorization` header on the `httpx2.AsyncClient` you hand to `streamable_http_client` (**[Client transports](../client/transports.md#bring-your-own-httpx2asyncclient)** has it); for a host, it is wherever that host's server entry takes request headers, usually a `headers` block. 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:

```text
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.
* `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](https://datatracker.ietf.org/doc/html/rfc9728) 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.
* `token_verifier=` alone is a complete gate, and the right one for a token you hand out yourself. Add `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` when a real authorization server issues the tokens.
* With `AuthSettings`, the SDK publishes [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) 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 client never see it.

Expand Down
27 changes: 27 additions & 0 deletions docs_src/authorization/tutorial003.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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
75 changes: 35 additions & 40 deletions src/mcp/server/lowlevel/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -737,7 +737,18 @@ def streamable_http_app(
custom_starlette_routes: list[Route] | None = None,
debug: bool = False,
) -> Starlette:
"""Return an instance of the StreamableHTTP server app."""
"""Return an instance of the StreamableHTTP server app.

`token_verifier` is the bearer gate: with one, every request to the MCP
endpoint must carry an `Authorization: Bearer` token the verifier
accepts, and anything else is answered 401. `auth` describes that gate
to clients: its `required_scopes` are enforced, and when
`resource_server_url` is set the app serves RFC 9728 protected-resource
metadata and points the 401 challenge at it. Without a verifier nothing
is gated. `auth_server_provider` (with `auth`) additionally mounts the
SDK's authorization-server routes, advertised with `auth.issuer_url`
as the issuer.
"""
# Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6)
if transport_security is None and host in ("127.0.0.1", "localhost", "::1"):
transport_security = TransportSecuritySettings(
Expand Down Expand Up @@ -765,57 +776,41 @@ def streamable_http_app(
# Create routes
routes: list[Route | Mount] = []
middleware: list[Middleware] = []
required_scopes: list[str] = []

# Set up auth if configured
if auth:
required_scopes = auth.required_scopes or []

# Add auth middleware if token verifier is available
if token_verifier:
middleware = [
Middleware(
AuthenticationMiddleware,
backend=BearerAuthBackend(token_verifier),
),
Middleware(AuthContextMiddleware),
]

# Add auth endpoints if auth server provider is configured
if auth_server_provider:
routes.extend(
create_auth_routes(
provider=auth_server_provider,
issuer_url=auth.issuer_url,
service_documentation_url=auth.service_documentation_url,
client_registration_options=auth.client_registration_options,
revocation_options=auth.revocation_options,
identity_assertion_enabled=auth.identity_assertion_enabled,
)

# Embedded authorization server (the legacy all-in-one shape)
if auth and auth_server_provider:
Comment on lines +780 to +781

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) streamable_http_app silently ignores auth_server_provider when auth is None, and this change widens the masked shape: on base, token_verifier + auth_server_provider without auth built an app that 401'd every request, making the missing auth= unmissable, while after merging the same call serves and gates normally but silently omits the embedded AS routes (/authorize, /token, /register), so OAuth clients have no way to obtain a token. Fix: fail fast (or warn) when auth_server_provider is passed without auth, matching MCPServer.__init__'s ValueError, instead of dropping the provider. The PR calls this "ignored, as before", but that shape previously failed loudly.

Extended reasoning...

At src/mcp/server/lowlevel/server.py:781 the embedded-AS routes are added only under if auth and auth_server_provider:, while line 797 if token_verifier: installs AuthenticationMiddleware and the RequireAuthMiddleware gate regardless of auth. On base, the middleware was under if auth: (base lines ~771-782), so a lowlevel caller who wrote server.streamable_http_app(token_verifier=ProviderTokenVerifier(p), auth_server_provider=p) and forgot auth=AuthSettings(...) got RequireAuthMiddleware with no authentication backend: scope["user"] was never an AuthenticatedUser, so every request — valid provider-issued token included — was answered 401 and the misconfiguration surfaced on the first request. After this change the same call authenticates tokens the provider validates and serves them normally, but create_auth_routes never runs: no /authorize, /token, or /register routes exist, so no OAuth client can complete a flow against the embedded AS, and nothing in logs or responses points at the dropped provider (the metadata path just 404s). MCPServer raises ValueError for…

Verification: pre-existing — acknowledged in diff: the PR description states "At the low level, auth_server_provider without auth is still silently ignored, as before," and that acknowledgment is accurate for the missing routes. Trigger: a lowlevel caller invokes Server.streamable_http_app(token_verifier=..., auth_server_provider=..., auth=None) — nothing validates this shape at the lowlevel (only…

routes.extend(
create_auth_routes(
provider=auth_server_provider,
issuer_url=auth.issuer_url,
service_documentation_url=auth.service_documentation_url,
client_registration_options=auth.client_registration_options,
revocation_options=auth.revocation_options,
identity_assertion_enabled=auth.identity_assertion_enabled,
)
)

# Set up routes with or without auth
# A token verifier is the bearer gate: authenticate every request and
# refuse the MCP endpoint to anything the verifier does not accept.
# `auth` only adds to that: required scopes, and the RFC 9728 metadata
# URL the 401 challenge points at.
if token_verifier:
# Determine resource metadata URL
middleware = [
Middleware(AuthenticationMiddleware, backend=BearerAuthBackend(token_verifier)),
Middleware(AuthContextMiddleware),
]
required_scopes = (auth.required_scopes if auth else None) or []
resource_metadata_url = None
if auth and auth.resource_server_url: # pragma: no branch
# Build compliant metadata URL for WWW-Authenticate header
if auth and auth.resource_server_url:
resource_metadata_url = build_resource_metadata_url(auth.resource_server_url)

routes.append(
Route(
streamable_http_path,
endpoint=RequireAuthMiddleware(streamable_http_app, required_scopes, resource_metadata_url),
)
)
else:
# Auth is disabled, no wrapper needed
routes.append(
Route(
streamable_http_path,
endpoint=streamable_http_app,
)
)
routes.append(Route(streamable_http_path, endpoint=streamable_http_app))
Comment on lines 812 to +813

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 pre-existing, not blocking: Pre-existing, security-relevant: lowlevel streamable_http_app(auth=..., token_verifier=None) builds a fail-open app — /mcp is mounted ungated here while lines 816-823 still publish RFC 9728 metadata advertising the resource as OAuth-protected with required scopes; MCPServer refuses this exact shape ("Must specify either auth_server_provider or token_verifier with auth settings") but the low level accepts it silently, and the new docstring now blesses it. Fix: mirror the high-level validation — reject (or at least log a warning for) auth passed with neither token_verifier nor auth_server_provider, so AuthSettings can never be advertised yet unenforced.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Extended reasoning...

Path: Server.streamable_http_app is called with auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...]) but no token_verifier (e.g. a lowlevel user who read the MCPServer docs where auth= implies gating, or who wired a provider-less RS and forgot the verifier). Line 797 if token_verifier: is false, so no AuthenticationMiddleware and no RequireAuthMiddleware are installed and line 813 mounts the raw StreamableHTTPASGIApp at /mcp — every unauthenticated request is served. Yet line 816 if auth and auth.resource_server_url: is true, so create_protected_resource_routes publishes /.well-known/oauth-protected-resource metadata declaring authorization_servers and scopes_supported: the app tells the world it is protected while enforcing nothing. No safeguard catches it: unlike MCPServer.init (src/mcp/server/mcpserver/server.py:252-253), the low level has no validation, raises nothing, and logs nothing. The base branch behaves identically (this is pre-existing), but this PR restructures exactly this block, removes the # pragma: no branch markers, fixes the sibling…

Verification: pre-existing — acknowledged in diff: the new docstring (src/mcp/server/lowlevel/server.py:747-748) states "Without a verifier nothing is gated", accurately describing the hazard. Triggering condition: a lowlevel caller invokes Server.streamable_http_app(auth=AuthSettings(..., resource_server_url=...), token_verifier=None). Mechanism verified: line 797 if token_verifier: is false, so no…


# Add protected resource metadata endpoint if configured as RS
if auth and auth.resource_server_url:
Expand Down
72 changes: 29 additions & 43 deletions src/mcp/server/mcpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier
from mcp.server.auth.routes import build_resource_metadata_url, create_auth_routes, create_protected_resource_routes
from mcp.server.auth.settings import AuthSettings
from mcp.server.caching import CacheableMethod, CacheHint
from mcp.server.context import HandlerResult, ServerMiddleware, ServerRequestContext
Expand Down Expand Up @@ -242,14 +243,16 @@ def __init__(
# User middleware runs inside the SDK's built-ins (OpenTelemetry, then the
# request-state boundary), outermost-first in the order given.
self._lowlevel_server.middleware.extend(middleware or ())
# Validate auth configuration
# Validate auth configuration. A token_verifier on its own is a plain
# bearer gate; `auth` is what publishes metadata about it, so it needs
# something to gate with, and an embedded AS needs `auth` for its issuer.
if self.settings.auth is not None:
if auth_server_provider and token_verifier: # pragma: no cover
if auth_server_provider and token_verifier:
raise ValueError("Cannot specify both auth_server_provider and token_verifier")
if not auth_server_provider and not token_verifier: # pragma: no cover
raise ValueError("Must specify either auth_server_provider or token_verifier when auth is enabled")
elif auth_server_provider or token_verifier:
raise ValueError("Cannot specify auth_server_provider or token_verifier without auth settings")
if not auth_server_provider and not token_verifier:
raise ValueError("Must specify either auth_server_provider or token_verifier with auth settings")
elif auth_server_provider:
raise ValueError("Cannot specify auth_server_provider without auth settings")

self._auth_server_provider = auth_server_provider
self._token_verifier = token_verifier
Expand Down Expand Up @@ -1177,45 +1180,30 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no
middleware: list[Middleware] = []
required_scopes: list[str] = []

# Set up auth if configured
if self.settings.auth: # pragma: no cover
required_scopes = self.settings.auth.required_scopes or []

# Add auth middleware if token verifier is available
if self._token_verifier:
middleware = [
# extract auth info from request (but do not require it)
Middleware(
AuthenticationMiddleware,
backend=BearerAuthBackend(self._token_verifier),
),
# Add the auth context middleware to store
# authenticated user in a contextvar
Middleware(AuthContextMiddleware),
]

# Add auth endpoints if auth server provider is configured
if self._auth_server_provider:
from mcp.server.auth.routes import create_auth_routes

routes.extend(
create_auth_routes(
provider=self._auth_server_provider,
issuer_url=self.settings.auth.issuer_url,
service_documentation_url=self.settings.auth.service_documentation_url,
client_registration_options=self.settings.auth.client_registration_options,
revocation_options=self.settings.auth.revocation_options,
identity_assertion_enabled=self.settings.auth.identity_assertion_enabled,
)
# Add auth endpoints if auth server provider is configured
if self.settings.auth and self._auth_server_provider: # pragma: no cover
routes.extend(
create_auth_routes(
provider=self._auth_server_provider,
issuer_url=self.settings.auth.issuer_url,
service_documentation_url=self.settings.auth.service_documentation_url,
client_registration_options=self.settings.auth.client_registration_options,
revocation_options=self.settings.auth.revocation_options,
identity_assertion_enabled=self.settings.auth.identity_assertion_enabled,
)
)

# When auth is configured, require authentication
if self._token_verifier: # pragma: no cover
# A token verifier is the bearer gate (see Server.streamable_http_app)
if self._token_verifier:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a valid TokenVerifier is falsey, this condition skips AuthenticationMiddleware and leaves /sse and /messages/ publicly accessible. Check verifier presence with is not None instead of truthiness.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/server/mcpserver/server.py, line 1197:

<comment>When a valid `TokenVerifier` is falsey, this condition skips `AuthenticationMiddleware` and leaves `/sse` and `/messages/` publicly accessible. Check verifier presence with `is not None` instead of truthiness.</comment>

<file context>
@@ -1177,45 +1180,30 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send):  # pragma: no
-        # When auth is configured, require authentication
-        if self._token_verifier:  # pragma: no cover
+        # A token verifier is the bearer gate (see Server.streamable_http_app)
+        if self._token_verifier:
+            middleware = [
+                Middleware(AuthenticationMiddleware, backend=BearerAuthBackend(self._token_verifier)),
</file context>
Suggested change
if self._token_verifier:
if self._token_verifier is not None:

middleware = [
Middleware(AuthenticationMiddleware, backend=BearerAuthBackend(self._token_verifier)),
Middleware(AuthContextMiddleware),
]
if self.settings.auth:
required_scopes = self.settings.auth.required_scopes or []
# Determine resource metadata URL
resource_metadata_url = None
if self.settings.auth and self.settings.auth.resource_server_url:
from mcp.server.auth.routes import build_resource_metadata_url

# Build compliant metadata URL for WWW-Authenticate header
resource_metadata_url = build_resource_metadata_url(self.settings.auth.resource_server_url)

Expand Down Expand Up @@ -1254,9 +1242,7 @@ async def sse_endpoint(request: Request) -> Response: # pragma: no cover
)
)
# Add protected resource metadata endpoint if configured as RS
if self.settings.auth and self.settings.auth.resource_server_url: # pragma: no cover
from mcp.server.auth.routes import create_protected_resource_routes

if self.settings.auth and self.settings.auth.resource_server_url:
routes.extend(
create_protected_resource_routes(
resource_url=self.settings.auth.resource_server_url,
Expand Down
Loading
Loading