Skip to content

Is one-auth-configuration-per-server a deliberate constraint? #3488

Description

@Neko1313

Initial Checks

Release line

2.x (current stable)

Description

Hi! Is one-auth-configuration-per-server in the Python SDK a deliberate constraint, or just something nobody has needed yet?

Use case

I have an MCP server for text search that enforces per-user article permissions from our main system. One deployment needs to serve two kinds of caller:

  1. Service-to-service (internal). Our frontend authenticates the user, the backend receives only the user's token and passes it to the MCP server — this avoids redirect-based logins between internal services. The MCP server then exchanges that token with our SSO for a token valid for a third system, so it needs a confidential client with a client_secret.
  2. Interactive (external). I'd like the same server to also expose a "public" entry point, so a user can add it to Claude Desktop / Codex and go through normal OAuth with a public client (PKCE, no secret).

Today AuthSettings allows exactly one configuration

  • issuer_url is a single AnyHttpUrl, and the server always publishes it as a one-element list — server/mcpserver/server.py:1207:
authorization_servers=[self.settings.auth.issuer_url]
  • the client only ever reads the first entry — client/auth/oauth2.py:349 and :630, with a # todo: try all authorization_servers to find the OASM
  • and there is one token_verifier per MCPServer.

The workaround, and why it doesn't hold up

The obvious approach is two MCPServer instances sharing the tool functions (see Example Code below). Stacking the decorators is fine (tool() returns the function unchanged). Mounting is where it falls apart. Both apps can't be mounted at / — the first one matches everything and the second is never reached. And once the second is mounted under a prefix, its RFC 9728 metadata route (generated from resource_server_url) is served from under that prefix:

200  /public/.well-known/oauth-protected-resource/public/mcp   <- where it actually is
404  /.well-known/oauth-protected-resource/public/mcp          <- where the client looks

So the second server is undiscoverable unless I re-register the well-known route at the app root by hand. That's the part that feels like it should be SDK support rather than a workaround.

Question

Is one auth config per server intentional — and if so, what's the recommended way to cover both cases? Or would you be open to multiple auth configurations / multiple token verifiers per server? Happy to put up a PR if there's interest.

Example Code

mcp = MCPServer(token_verifier=JwtTokenVerifier(),
                auth=AuthSettings(resource_server_url="https://host/mcp", ...))
mcp_public = MCPServer(token_verifier=PublicJwtTokenVerifier(),
                       auth=AuthSettings(resource_server_url="https://host/public/mcp", ...))

@mcp.tool()
@mcp_public.tool()
async def search(...): ...

Python & MCP Python SDK

Python 3.14.2
MCP Python SDK 2.0.0

Activity

  1. arhancanli commented on Oct 7, 2026

    @arhancanli

    Two separate things in here, and I think only one of them needs SDK changes.

    The 404 is the spec behaving as written. RFC 9728 section 3.1 says a client builds the metadata URL by inserting /.well-known/oauth-protected-resource between the host and the path of the resource identifier. For https://host/public/mcp that is /.well-known/oauth-protected-resource/public/mcp at the host root, which is exactly the URL your trace shows returning 404. Mounting the sub-app under /public serves the route one level too deep, because Mount strips the prefix for everything inside it. Until the SDK handles this, the fix is the one you mention: a root-level route that delegates to the sub-app's metadata handler, e.g. a Route("/.well-known/oauth-protected-resource/public/mcp", ...) in the parent Starlette app. That part does look like SDK support (or at least a documented pattern) for any server that is mounted under a prefix, with or without your two-config case.

    You may not need two configurations at all. "Confidential client with a secret" vs "public client with PKCE" is a property of how each client is registered at the authorization server, not of the resource server. The MCP server only has to answer one question: is this bearer token valid for me (signature, iss, aud/resource, expiry, scopes)? An internal caller forwarding the user's token and a Claude Desktop user who got theirs through PKCE both present a JWT that satisfies that, so one token_verifier that accepts the issuer/audience pairs you trust covers both, with issuer_url pointing at the SSO. The client secret you need for the token exchange (RFC 8693) lives in your server's outbound call to the SSO and does not depend on how the inbound token was obtained.

    The case where one deployment truly needs two configs is different authorization servers per audience with different advertised metadata. In that case the two-instance route is reasonable, and then the root-level well-known route above is the missing piece. Which of the two is yours?

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    questionFurther information is requested

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions