Skip to content

Authentication in High Level MCPServer #3283

Description

@saurabhlalsaxena

Question

I'm trying to understand the authentication model in the new MCP Python SDK v2.

I want to implement the simplest possible authentication for a remote Streamable HTTP MCP server:

  1. The server has a pre-configured bearer token.
  2. The client sends Authorization: Bearer <token>.
  3. The server verifies the token.
  4. If valid, the MCP request is allowed.
  5. There is no OAuth login flow and no Authorization Server involved.

I initially tried using TokenVerifier:

class StaticTokenVerifier(TokenVerifier):
    async def verify_token(self, token: str) -> AccessToken | None:
        if token == ACCESS_TOKEN:
            return AccessToken(token=token)

        return None

However, MCPServer requires AuthSettings whenever a token_verifier is supplied. AuthSettings in my version requires both issuer_url and resource_server_url.

I don't understand why issuer_url is required for this use case. There is no Authorization Server issuing the token—the token is simply pre-configured on the MCP server.

Adding AuthSettings also appears to cause the client to enter the OAuth discovery/authorization flow. For example, I encountered:

The connection is now reaching the OAuth authorization step,
but this server does not implement an /authorize endpoint.

I also encountered a protected-resource URL validation error when the URL differed only by localhost vs 127.0.0.1:

Protected resource [http://localhost:10000/mcp] does not match expected
[http://127.0.0.1:10000/mcp] (or origin)

My understanding is that TokenVerifier and the OAuth discovery/authorization-server configuration are separate concerns. The low-level SDK code appears to support BearerAuthBackend(token_verifier) independently, while resource_server_url is used for protected-resource metadata.

Is there a supported way in MCP Python SDK v2 to implement simple bearer-token authentication for Streamable HTTP without configuring an OAuth Authorization Server and without having to implement or manually compose Starlette middleware?

In other words, I'm looking for the equivalent of:

HTTP Request
     ↓
Authorization: Bearer alice-token
     ↓
TokenVerifier
     ↓
valid → MCP request
invalid → 401

without requiring:

Authorization Server
    ↓
/authorize
/token
OAuth discovery
issuer_url
resource_server_url

Is this supported by the high-level MCPServer API, or is OAuth intentionally a prerequisite for using TokenVerifier?

Activity

  1. maxisbey commented on Aug 11, 2026

    @maxisbey
    Contributor

    Yeah, I agree this is awkward. token_verifier= shouldn't make you invent an authorization server, and the docs only ever show the "there's an AS somewhere" shape.

    To answer the direct question: OAuth is a constructor-shape prerequisite today, not a runtime one. Nothing ever contacts issuer_url; in resource-server-only mode it's just echoed into the /.well-known/oauth-protected-resource document. And resource_server_url is typed AnyHttpUrl | None, so if you pass None explicitly that document isn't served at all and an unauthenticated request gets a plain 401 with WWW-Authenticate: Bearer error="invalid_token" and no discovery pointer, which is exactly the flow you drew.

    A few things that I think explain what you hit:

    • The verifier itself
      • AccessToken requires client_id and scopes as well as token.
      • AccessToken(token=token) raises a validation error inside verify_token, so the correct token gets a 500 and everything else a 401. The static token could never succeed as written.
    • The OAuth flow
      • The server config doesn't push a client into OAuth; a 401 does.
      • That "(or origin)" message is from the TypeScript SDK's client. Clients built on it (Inspector etc.) react to a 401 by fetching the metadata document and chasing authorization_servers, which for a placeholder issuer is a dead end.
      • If the client sends Authorization: Bearer … from the first request, none of that runs. In Inspector that's the custom headers section (leave the OAuth settings empty); most hosts take a headers map in the server config.
    • localhost vs 127.0.0.1
      • That's the client correctly (per RFC 9728) rejecting a metadata document whose resource doesn't match the URL it dialed.
      • If you keep resource_server_url, set it to exactly the URL clients connect to.
      • With resource_server_url=None there's no document to mismatch.

    Here's a working example end to end:

    server.py
    import secrets
    
    from pydantic import AnyHttpUrl
    
    from mcp.server import MCPServer
    from mcp.server.auth.middleware.auth_context import get_access_token
    from mcp.server.auth.provider import AccessToken, TokenVerifier
    from mcp.server.auth.settings import AuthSettings
    
    TOKEN = "alice-token"  # read from the environment in real life, and serve over TLS if it's remote
    
    
    class StaticTokenVerifier(TokenVerifier):
        async def verify_token(self, token: str) -> AccessToken | None:
            if secrets.compare_digest(token, TOKEN):
                return AccessToken(token=token, client_id="alice", scopes=[])
            return None
    
    
    mcp = MCPServer(
        "notes",
        token_verifier=StaticTokenVerifier(),
        auth=AuthSettings(
            issuer_url=AnyHttpUrl("https://unused.invalid"),  # placeholder, never contacted
            resource_server_url=None,  # no metadata document, plain 401
        ),
    )
    
    
    @mcp.tool()
    def whoami() -> str:
        token = get_access_token()
        return token.client_id if token else "anonymous"
    
    
    if __name__ == "__main__":
        mcp.run("streamable-http", port=10000)
    client.py
    import anyio
    import httpx2
    
    from mcp import Client
    from mcp.client.streamable_http import streamable_http_client
    
    
    async def main() -> None:
        async with httpx2.AsyncClient(
            headers={"Authorization": "Bearer alice-token"},
            timeout=httpx2.Timeout(30.0, read=300.0),
            follow_redirects=True,
        ) as http:
            transport = streamable_http_client("http://127.0.0.1:10000/mcp", http_client=http)
            async with Client(transport) as client:
                result = await client.call_tool("whoami", {})
                print(result.content[0].text)  # alice
    
    
    anyio.run(main)

    There's a runnable version of this in examples/stories/bearer_auth/ (it keeps resource_server_url set so you can see the metadata route too), and the client-side header pattern is under "Bring your own httpx2.AsyncClient" in docs/client/transports.md.

    Hopefully that gets you going, but let me know if it doesn't :)

    AI Disclaimer

  2. saurabhlalsaxena commented on Aug 11, 2026

    @saurabhlalsaxena
    Author

    Thanks. I tried running your code and using the mcp inspector to test the MCP, I still get the same error:

    Failed to connect to "mcp-server"
    Protected resource http://localhost:10000/mcp does not match expected http://127.0.0.1:10000/mcp (or origin)
    

    Here are the server logs:

    INFO:     Application startup complete.
    INFO:     Uvicorn running on http://127.0.0.1:10000 (Press CTRL+C to quit)
    INFO:     127.0.0.1:51525 - "GET / HTTP/1.1" 404 Not Found
    INFO:     127.0.0.1:51525 - "GET /json/version HTTP/1.1" 404 Not Found
    INFO:     127.0.0.1:51527 - "GET / HTTP/1.1" 404 Not Found
    INFO:     127.0.0.1:51542 - "POST /mcp HTTP/1.1" 401 Unauthorized
    INFO:     127.0.0.1:51545 - "GET /.well-known/oauth-authorization-server HTTP/1.1" 404 Not Found
    INFO:     127.0.0.1:51545 - "GET /.well-known/openid-configuration HTTP/1.1" 404 Not Found
    INFO:     127.0.0.1:51542 - "POST /mcp HTTP/1.1" 401 Unauthorized
    INFO:     127.0.0.1:51545 - "GET /.well-known/oauth-authorization-server HTTP/1.1" 404 Not Found
    INFO:     127.0.0.1:51545 - "GET /.well-known/openid-configuration HTTP/1.1" 404 Not Found
    INFO:     127.0.0.1:51568 - "POST /mcp HTTP/1.1" 401 Unauthorized
    INFO:     127.0.0.1:51569 - "GET /.well-known/oauth-authorization-server HTTP/1.1" 404 Not Found
    INFO:     127.0.0.1:51569 - "GET /.well-known/openid-configuration HTTP/1.1" 404 Not Found
    INFO:     127.0.0.1:51568 - "POST /mcp HTTP/1.1" 401 Unauthorized
    INFO:     127.0.0.1:51569 - "GET /.well-known/oauth-authorization-server HTTP/1.1" 404 Not Found
    INFO:     127.0.0.1:51569 - "GET /.well-known/openid-configuration HTTP/1.1" 404 Not Found
  3. saurabhlalsaxena commented on Aug 11, 2026

    @saurabhlalsaxena
    Author

    Ok. So got it to work. Had missed your point on using TypeScript SDK client. Here is what I had to change:

    In MCP Inspector, added this under Custom Headers

    Authorization: Bearer alice-token
    
    Name | Value
    Authorization | Bearer alice-token

    And left the OAuth configuration empty.

    My feedback would still be that the Auth flow is fragmented. Maybe Auth needs to be a separate library (mcp-auth) instead of trying to incorporate it in the MCP SDK, just as you have 'flask-login' for flask.

    Also the issues with the TypeScript SDK client seems like a bug.

  4. added
    P3Nice to haves, rare edge cases
    v1Affects the v1.x maintenance line
    v2Affects the v2 line (2.x on main)
    on Aug 12, 2026
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

    P3Nice to haves, rare edge casesquestionFurther information is requestedv1Affects the v1.x maintenance linev2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions