GeezerVisonThe World Wide Web, restored to 800 pixels of glory. | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Home|About|Gaming cabinet|Archives|How it works|Starter sites | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
GeezerVison is showing github.com — Let a token verifier gate the server without AuthSettings by maxisbey · Pull Request #3292 · modelcontextprotocol/python-sdk Conversation
Contributor
Motivation and ContextCloses #3283 (see also #431, #702). Someone with a pre-shared token and no authorization server anywhere had to write There was also a low-level inconsistency behind it: What changes:
What this deliberately doesn't do:
How Has This Been Tested?
Breaking ChangesNone. A constructor call that used to raise Types of changes
Checklist
Additional contextThe spec makes authorization OPTIONAL and only SHOULD for HTTP transports, and
Sorry, something went wrong. Uh oh!
There was an error while loading. Please reload this page. All reactions
|
There was an error while loading. Please reload this page.
📚 Documentation preview
|
Sorry, something went wrong.
There was an error while loading. Please reload this page.
`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.
The reason will be displayed to describe this comment to others. Learn more.
2 issues found across 9 files
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
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| # 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.
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.
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: |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| * `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.
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.
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)**. |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| # Embedded authorization server (the legacy all-in-one shape) | ||
| if auth and auth_server_provider: |
There was a problem 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.
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…
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| 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.
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.
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…
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
Tested this locally (f94ffd5): auth suites pass (366), and an end-to-end check confirms verifier-only Heads-up for the rebase: it now conflicts with #3447 in both Tested with AI assistance; I ran the checks and read the output. |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
At least 1 approving review is required to merge this pull request.
There was an error while loading. Please reload this page.
Successfully merging this pull request may close these issues.
Authentication in High Level MCPServer
There was an error while loading. Please reload this page.