Skip to content

Let a token verifier gate the server without AuthSettings - #3292

Open
maxisbey wants to merge 1 commit into
mainfrom
token-verifier-without-auth-settings
Open

Let a token verifier gate the server without AuthSettings#3292
maxisbey wants to merge 1 commit into
mainfrom
token-verifier-without-auth-settings

Conversation

@maxisbey

Copy link
Copy Markdown
Contributor

MCPServer(token_verifier=...) no longer requires auth=AuthSettings(...). A verifier on its own is now a plain bearer gate; AuthSettings is 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 mode issuer_url is 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 no auth= was accepted but built an app that 401'd every request, valid token included, because AuthenticationMiddleware(BearerAuthBackend) was installed under if auth: while RequireAuthMiddleware was installed under if token_verifier:. MCPServer refused the same shape with a ValueError, so the two layers disagreed about one state.

What changes:

  • lowlevel.Server.streamable_http_app and MCPServer.sse_app: the authentication backend + auth-context middleware are installed whenever a verifier is present. auth layers on required scopes, the metadata route, and the resource_metadata pointer in the 401. Every previously-valid combination produces the same routes (same order), middleware and responses as before.
  • MCPServer.__init__: a bare token_verifier= is accepted. Still refused: auth= with nothing to gate with, auth_server_provider= without auth= (it needs the issuer), and both a provider and a verifier.
  • docs/run/authorization.md: new "Just a pre-shared token" section with a runnable docs_src example (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:

  • Split the resource-server fields out of 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.
  • Deduplicate the auth wiring between Server.streamable_http_app and MCPServer.sse_app.
  • Change how a TokenVerifier that raises is surfaced (still a 500 from Starlette's AuthenticationMiddleware).
  • At the low level, auth_server_provider without auth is still silently ignored, as before.

How Has This Been Tested?

  • New docs tests for the pre-shared-token example (route list, exact 401 challenge, no metadata route, Client round trip with a static header), an interaction test for the low-level verifier-only shape, two sse_app tests (verifier-only, and verifier + settings), and constructor tests for the refused shapes. Three # pragma: no cover markers on the touched wiring/validation come off as a result; the embedded-AS-over-SSE branch keeps its pre-existing one.
  • Driven end to end under uvicorn with curl and the SDK client over both streamable HTTP and SSE: no/wrong token → 401 without resource_metadata, metadata paths → 404, valid token → initialize 200 and whoami returns the verifier's client_id; non-ASCII and malformed Authorization headers → 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 ValueError now succeeds; everything that worked before behaves identically. Two error messages are reworded (... without auth settings now names only auth_server_provider; ... when auth is enabled... with auth settings).

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

The spec makes authorization OPTIONAL and only SHOULD for HTTP transports, and basic allows 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.

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-3292.mcp-python-docs.pages.dev
Deployment https://58c37665.mcp-python-docs.pages.dev
Commit f94ffd5
Triggered by @maxisbey
Updated 2026-09-04 15:11:50 UTC

`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.
@maxisbey
maxisbey force-pushed the token-verifier-without-auth-settings branch from 196b959 to f94ffd5 Compare September 4, 2026 15:09
@maxisbey
maxisbey marked this pull request as ready for review September 4, 2026 15:16

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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:

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:

Comment thread docs/run/authorization.md
* `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)**.

@claude claude Bot left a comment

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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

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

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…

Comment on lines 812 to +813
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))

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…

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Authentication in High Level MCPServer

1 participant