Skip to content

Commit 196b959

Browse files
committed
Let a token verifier gate the server without AuthSettings
`MCPServer(token_verifier=...)` no longer needs `auth=AuthSettings(...)`. On its own a verifier is now a plain bearer gate: requests without a token it accepts get a 401 whose `WWW-Authenticate` carries no `resource_metadata`, no protected-resource metadata route is published, and `get_access_token()` works as before. `AuthSettings` keeps its job of describing that gate to OAuth clients (required scopes, RFC 9728 metadata, the discovery pointer in the 401), so it is what you add when a real authorization server issues the tokens. Previously the constructor refused a verifier without settings, which forced anyone with a pre-shared token to invent an issuer URL, and the low-level `Server.streamable_http_app(token_verifier=...)` accepted the same shape but answered every request 401, valid token included, because the authentication backend was only installed when settings were given. Both wiring sites (and `MCPServer.sse_app`) now install the backend whenever a verifier is present. The authorization docs gain a "Just a pre-shared token" section with a runnable example, and the constructor still refuses the two shapes that cannot work: settings with nothing to gate with, and an embedded authorization-server provider without settings for its issuer.
1 parent a4f4ccd commit 196b959

9 files changed

Lines changed: 276 additions & 94 deletions

File tree

docs/run/authorization.md

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Authorization
22

3-
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.
3+
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.
44

55
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.
66

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

2525
* `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.
2626
* 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.
27-
* `token_verifier=` and `auth=` always travel together. Pass one without the other and `MCPServer(...)` raises a `ValueError` before it ever serves a request.
27+
* `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)**.
2828

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

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

114114
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)**.
115115

116+
## Just a pre-shared token
117+
118+
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=`:
119+
120+
```python title="server.py" hl_lines="8 13-15 18"
121+
--8<-- "docs_src/authorization/tutorial003.py"
122+
```
123+
124+
* 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()`.
125+
* 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.
126+
* 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.
127+
128+
!!! check
129+
Call `/mcp` with no token and the door is exactly as shut:
130+
131+
```text
132+
HTTP/1.1 401 Unauthorized
133+
WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required"
134+
135+
{"error": "invalid_token", "error_description": "Authentication required"}
136+
```
137+
138+
The same refusal as before, minus the `resource_metadata` that would have sent a client looking
139+
for an authorization server you don't have.
140+
116141
## Recap
117142

118143
* Over Streamable HTTP your server is an OAuth 2.1 **resource server**: it verifies tokens, it never issues them.
119144
* `TokenVerifier` is the whole integration surface: one async method, token in, `AccessToken | None` out.
120-
* `token_verifier=` and `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` always travel together.
121-
* 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.
145+
* `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.
146+
* 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.
122147
* `get_access_token()` in any handler is who's calling.
123148
* Authorization is an HTTP concern. `stdio` and the in-memory client never see it.
124149

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import os
2+
import secrets
3+
4+
from mcp.server import MCPServer
5+
from mcp.server.auth.middleware.auth_context import get_access_token
6+
from mcp.server.auth.provider import AccessToken, TokenVerifier
7+
8+
API_TOKEN = os.environ.get("NOTES_API_TOKEN") or secrets.token_urlsafe(32)
9+
10+
11+
class PresharedTokenVerifier(TokenVerifier):
12+
async def verify_token(self, token: str) -> AccessToken | None:
13+
if secrets.compare_digest(token.encode(), API_TOKEN.encode()):
14+
return AccessToken(token=token, client_id="notes-client", scopes=[])
15+
return None
16+
17+
18+
mcp = MCPServer("Notes", token_verifier=PresharedTokenVerifier())
19+
20+
21+
@mcp.tool()
22+
def whoami() -> str:
23+
"""Report which client is calling."""
24+
token = get_access_token()
25+
if token is None:
26+
return "anonymous"
27+
return token.client_id

src/mcp/server/lowlevel/server.py

Lines changed: 35 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -734,7 +734,18 @@ def streamable_http_app(
734734
custom_starlette_routes: list[Route] | None = None,
735735
debug: bool = False,
736736
) -> Starlette:
737-
"""Return an instance of the StreamableHTTP server app."""
737+
"""Return an instance of the StreamableHTTP server app.
738+
739+
`token_verifier` is the bearer gate: with one, every request to the MCP
740+
endpoint must carry an `Authorization: Bearer` token the verifier
741+
accepts, and anything else is answered 401. `auth` describes that gate
742+
to clients: its `required_scopes` are enforced, and when
743+
`resource_server_url` is set the app serves RFC 9728 protected-resource
744+
metadata and points the 401 challenge at it. Without a verifier nothing
745+
is gated. `auth_server_provider` (with `auth`) additionally mounts the
746+
SDK's authorization-server routes, advertised with `auth.issuer_url`
747+
as the issuer.
748+
"""
738749
# Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6)
739750
if transport_security is None and host in ("127.0.0.1", "localhost", "::1"):
740751
transport_security = TransportSecuritySettings(
@@ -760,57 +771,41 @@ def streamable_http_app(
760771
# Create routes
761772
routes: list[Route | Mount] = []
762773
middleware: list[Middleware] = []
763-
required_scopes: list[str] = []
764-
765-
# Set up auth if configured
766-
if auth:
767-
required_scopes = auth.required_scopes or []
768-
769-
# Add auth middleware if token verifier is available
770-
if token_verifier:
771-
middleware = [
772-
Middleware(
773-
AuthenticationMiddleware,
774-
backend=BearerAuthBackend(token_verifier),
775-
),
776-
Middleware(AuthContextMiddleware),
777-
]
778-
779-
# Add auth endpoints if auth server provider is configured
780-
if auth_server_provider:
781-
routes.extend(
782-
create_auth_routes(
783-
provider=auth_server_provider,
784-
issuer_url=auth.issuer_url,
785-
service_documentation_url=auth.service_documentation_url,
786-
client_registration_options=auth.client_registration_options,
787-
revocation_options=auth.revocation_options,
788-
identity_assertion_enabled=auth.identity_assertion_enabled,
789-
)
774+
775+
# Embedded authorization server (the legacy all-in-one shape)
776+
if auth and auth_server_provider:
777+
routes.extend(
778+
create_auth_routes(
779+
provider=auth_server_provider,
780+
issuer_url=auth.issuer_url,
781+
service_documentation_url=auth.service_documentation_url,
782+
client_registration_options=auth.client_registration_options,
783+
revocation_options=auth.revocation_options,
784+
identity_assertion_enabled=auth.identity_assertion_enabled,
790785
)
786+
)
791787

792-
# Set up routes with or without auth
788+
# A token verifier is the bearer gate: authenticate every request and
789+
# refuse the MCP endpoint to anything the verifier does not accept.
790+
# `auth` only adds to that: required scopes, and the RFC 9728 metadata
791+
# URL the 401 challenge points at.
793792
if token_verifier:
794-
# Determine resource metadata URL
793+
middleware = [
794+
Middleware(AuthenticationMiddleware, backend=BearerAuthBackend(token_verifier)),
795+
Middleware(AuthContextMiddleware),
796+
]
797+
required_scopes = (auth.required_scopes if auth else None) or []
795798
resource_metadata_url = None
796-
if auth and auth.resource_server_url: # pragma: no branch
797-
# Build compliant metadata URL for WWW-Authenticate header
799+
if auth and auth.resource_server_url:
798800
resource_metadata_url = build_resource_metadata_url(auth.resource_server_url)
799-
800801
routes.append(
801802
Route(
802803
streamable_http_path,
803804
endpoint=RequireAuthMiddleware(streamable_http_app, required_scopes, resource_metadata_url),
804805
)
805806
)
806807
else:
807-
# Auth is disabled, no wrapper needed
808-
routes.append(
809-
Route(
810-
streamable_http_path,
811-
endpoint=streamable_http_app,
812-
)
813-
)
808+
routes.append(Route(streamable_http_path, endpoint=streamable_http_app))
814809

815810
# Add protected resource metadata endpoint if configured as RS
816811
if auth and auth.resource_server_url:

src/mcp/server/mcpserver/server.py

Lines changed: 29 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
5858
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware
5959
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier
60+
from mcp.server.auth.routes import build_resource_metadata_url, create_auth_routes, create_protected_resource_routes
6061
from mcp.server.auth.settings import AuthSettings
6162
from mcp.server.caching import CacheableMethod, CacheHint
6263
from mcp.server.context import HandlerResult, ServerMiddleware, ServerRequestContext
@@ -232,14 +233,16 @@ def __init__(
232233
# User middleware runs inside the SDK's built-ins (OpenTelemetry, then the
233234
# request-state boundary), outermost-first in the order given.
234235
self._lowlevel_server.middleware.extend(middleware or ())
235-
# Validate auth configuration
236+
# Validate auth configuration. A token_verifier on its own is a plain
237+
# bearer gate; `auth` is what publishes metadata about it, so it needs
238+
# something to gate with, and an embedded AS needs `auth` for its issuer.
236239
if self.settings.auth is not None:
237-
if auth_server_provider and token_verifier: # pragma: no cover
240+
if auth_server_provider and token_verifier:
238241
raise ValueError("Cannot specify both auth_server_provider and token_verifier")
239-
if not auth_server_provider and not token_verifier: # pragma: no cover
240-
raise ValueError("Must specify either auth_server_provider or token_verifier when auth is enabled")
241-
elif auth_server_provider or token_verifier:
242-
raise ValueError("Cannot specify auth_server_provider or token_verifier without auth settings")
242+
if not auth_server_provider and not token_verifier:
243+
raise ValueError("Must specify either auth_server_provider or token_verifier with auth settings")
244+
elif auth_server_provider:
245+
raise ValueError("Cannot specify auth_server_provider without auth settings")
243246

244247
self._auth_server_provider = auth_server_provider
245248
self._token_verifier = token_verifier
@@ -1121,45 +1124,30 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no
11211124
middleware: list[Middleware] = []
11221125
required_scopes: list[str] = []
11231126

1124-
# Set up auth if configured
1125-
if self.settings.auth: # pragma: no cover
1126-
required_scopes = self.settings.auth.required_scopes or []
1127-
1128-
# Add auth middleware if token verifier is available
1129-
if self._token_verifier:
1130-
middleware = [
1131-
# extract auth info from request (but do not require it)
1132-
Middleware(
1133-
AuthenticationMiddleware,
1134-
backend=BearerAuthBackend(self._token_verifier),
1135-
),
1136-
# Add the auth context middleware to store
1137-
# authenticated user in a contextvar
1138-
Middleware(AuthContextMiddleware),
1139-
]
1140-
1141-
# Add auth endpoints if auth server provider is configured
1142-
if self._auth_server_provider:
1143-
from mcp.server.auth.routes import create_auth_routes
1144-
1145-
routes.extend(
1146-
create_auth_routes(
1147-
provider=self._auth_server_provider,
1148-
issuer_url=self.settings.auth.issuer_url,
1149-
service_documentation_url=self.settings.auth.service_documentation_url,
1150-
client_registration_options=self.settings.auth.client_registration_options,
1151-
revocation_options=self.settings.auth.revocation_options,
1152-
identity_assertion_enabled=self.settings.auth.identity_assertion_enabled,
1153-
)
1127+
# Add auth endpoints if auth server provider is configured
1128+
if self.settings.auth and self._auth_server_provider: # pragma: no cover
1129+
routes.extend(
1130+
create_auth_routes(
1131+
provider=self._auth_server_provider,
1132+
issuer_url=self.settings.auth.issuer_url,
1133+
service_documentation_url=self.settings.auth.service_documentation_url,
1134+
client_registration_options=self.settings.auth.client_registration_options,
1135+
revocation_options=self.settings.auth.revocation_options,
1136+
identity_assertion_enabled=self.settings.auth.identity_assertion_enabled,
11541137
)
1138+
)
11551139

1156-
# When auth is configured, require authentication
1157-
if self._token_verifier: # pragma: no cover
1140+
# A token verifier is the bearer gate (see Server.streamable_http_app)
1141+
if self._token_verifier:
1142+
middleware = [
1143+
Middleware(AuthenticationMiddleware, backend=BearerAuthBackend(self._token_verifier)),
1144+
Middleware(AuthContextMiddleware),
1145+
]
1146+
if self.settings.auth:
1147+
required_scopes = self.settings.auth.required_scopes or []
11581148
# Determine resource metadata URL
11591149
resource_metadata_url = None
11601150
if self.settings.auth and self.settings.auth.resource_server_url:
1161-
from mcp.server.auth.routes import build_resource_metadata_url
1162-
11631151
# Build compliant metadata URL for WWW-Authenticate header
11641152
resource_metadata_url = build_resource_metadata_url(self.settings.auth.resource_server_url)
11651153

@@ -1198,9 +1186,7 @@ async def sse_endpoint(request: Request) -> Response: # pragma: no cover
11981186
)
11991187
)
12001188
# Add protected resource metadata endpoint if configured as RS
1201-
if self.settings.auth and self.settings.auth.resource_server_url: # pragma: no cover
1202-
from mcp.server.auth.routes import create_protected_resource_routes
1203-
1189+
if self.settings.auth and self.settings.auth.resource_server_url:
12041190
routes.extend(
12051191
create_protected_resource_routes(
12061192
resource_url=self.settings.auth.resource_server_url,

0 commit comments

Comments
 (0)