Let a token verifier gate the server without AuthSettings - #3292
Conversation
📚 Documentation preview
|
`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.
196b959 to
f94ffd5
Compare
There was a problem hiding this comment.
2 issues found across 9 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="docs/run/authorization.md">
<violation number="1" location="docs/run/authorization.md:27">
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.</violation>
</file>
<file name="src/mcp/server/mcpserver/server.py">
<violation number="1" location="src/mcp/server/mcpserver/server.py:1197">
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.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| # 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: |
There was a problem hiding this comment.
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>
| if self._token_verifier: | |
| if self._token_verifier is not None: |
| * `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)**. |
There was a problem hiding this comment.
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>
| * `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)**. |
| # Embedded authorization server (the legacy all-in-one shape) | ||
| if auth and auth_server_provider: |
There was a problem hiding this comment.
🟡 (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…
| 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)) |
There was a problem hiding this comment.
🟣 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…
MCPServer(token_verifier=...)no longer requiresauth=AuthSettings(...). A verifier on its own is now a plain bearer gate;AuthSettingsis what you add to describe that gate to OAuth clients.Motivation and Context
Closes #3283 (see also #431, #702).
Someone with a pre-shared token and no authorization server anywhere had to write
AuthSettings(issuer_url=<something made up>, resource_server_url=...)just to get past the constructor, and the made-up issuer then got advertised in the RFC 9728 metadata document, which sends OAuth-capable clients off to discover an AS that doesn't exist. In resource-server-only modeissuer_urlis never contacted; it's only echoed into that document. The TypeScript and Go SDKs both treat "verifier, metadata optional" as the primitive (requireBearerAuth({verifier}),RequireBearerToken(verifier, nil)); this brings the Python high-level API in line.There was also a low-level inconsistency behind it:
Server.streamable_http_app(token_verifier=V)with noauth=was accepted but built an app that 401'd every request, valid token included, becauseAuthenticationMiddleware(BearerAuthBackend)was installed underif auth:whileRequireAuthMiddlewarewas installed underif token_verifier:.MCPServerrefused the same shape with aValueError, so the two layers disagreed about one state.What changes:
lowlevel.Server.streamable_http_appandMCPServer.sse_app: the authentication backend + auth-context middleware are installed whenever a verifier is present.authlayers on required scopes, the metadata route, and theresource_metadatapointer in the 401. Every previously-valid combination produces the same routes (same order), middleware and responses as before.MCPServer.__init__: a baretoken_verifier=is accepted. Still refused:auth=with nothing to gate with,auth_server_provider=withoutauth=(it needs the issuer), and both a provider and a verifier.docs/run/authorization.md: new "Just a pre-shared token" section with a runnabledocs_srcexample (constant-time compare, token from the environment, fails closed when unset), and the "always travel together" wording is corrected.What this deliberately doesn't do:
AuthSettings(it still mixes embedded-AS config with RS discovery config). That's the principled follow-up and touches shipped 2.x surface; this change is a strict subset of it.Server.streamable_http_appandMCPServer.sse_app.TokenVerifierthat raises is surfaced (still a 500 from Starlette'sAuthenticationMiddleware).auth_server_providerwithoutauthis still silently ignored, as before.How Has This Been Tested?
Clientround trip with a static header), an interaction test for the low-level verifier-only shape, twosse_apptests (verifier-only, and verifier + settings), and constructor tests for the refused shapes. Three# pragma: no covermarkers on the touched wiring/validation come off as a result; the embedded-AS-over-SSE branch keeps its pre-existing one.resource_metadata, metadata paths → 404, valid token →initialize200 andwhoamireturns the verifier'sclient_id; non-ASCII and malformedAuthorizationheaders → 401../scripts/test(100% coverage, strict-no-cover), ruff, pyright, and the strict docs build pass locally.Breaking Changes
None. A constructor call that used to raise
ValueErrornow succeeds; everything that worked before behaves identically. Two error messages are reworded (... without auth settingsnow names onlyauth_server_provider;... when auth is enabled→... with auth settings).Types of changes
Checklist
Additional context
The spec makes authorization OPTIONAL and only SHOULD for HTTP transports, and
basicallows custom authentication strategies, so a pre-shared bearer with no metadata is outside the OAuth profile rather than in violation of it. The docs section says so in practical terms: with nothing to discover, the client has to arrive already holding the token.