Skip to content

Add MultiAuth for composing multiple token verification sources#3335

Merged
jlowin merged 6 commits intomainfrom
claude/review-issue-3035-E6W3s
Mar 2, 2026
Merged

Add MultiAuth for composing multiple token verification sources#3335
jlowin merged 6 commits intomainfrom
claude/review-issue-3035-E6W3s

Conversation

@jlowin
Copy link
Copy Markdown
Member

@jlowin jlowin commented Feb 28, 2026

Production servers often need to accept tokens from multiple authentication sources — an OAuth proxy for interactive MCP clients alongside JWT verification for machine-to-machine tokens, for example. Today there's no built-in way to compose these; you'd have to subclass AuthProvider and wire the chain yourself. MultiAuth makes this a one-liner.

MultiAuth wraps an optional auth server (the "primary" that owns routes and OAuth metadata) with additional TokenVerifier instances. On each request it tries the server first, then each verifier in order, accepting the first successful result. Routes and middleware delegate to the server; verifiers contribute only verify_token.

from fastmcp import FastMCP
from fastmcp.server.auth import MultiAuth, OAuthProxy
from fastmcp.server.auth.providers.jwt import JWTVerifier

auth = MultiAuth(
    server=OAuthProxy(
        issuer_url="https://login.example.com/...",
        client_id="my-app",
        client_secret="secret",
        base_url="https://my-server.com",
    ),
    verifiers=[
        JWTVerifier(
            jwks_uri="https://internal.example.com/.well-known/jwks.json",
            issuer="https://internal.example.com",
            audience="my-mcp-server",
        ),
    ],
)

mcp = FastMCP("My Server", auth=auth)

🤖 Generated with Claude Code

https://claude.ai/code/session_01WwKYDCqjM2FqYwY5ZNVvjb

Closes #3035
Closes #2499
Closes #1802

@marvin-context-protocol marvin-context-protocol Bot added auth Related to authentication (Bearer, JWT, OAuth, WorkOS) for client or server. feature Major new functionality. Reserved for 2-4 significant PRs per release. Not for issues. server Related to FastMCP server implementation or server-side functionality. labels Feb 28, 2026
@marvin-context-protocol
Copy link
Copy Markdown
Contributor

marvin-context-protocol Bot commented Feb 28, 2026

Test Failure Analysis

(This comment was updated with a new analysis for workflow run 22525457770.)

Summary: A single SSE timeout test fails on Python 3.10/ubuntu-latest due to a timing-sensitive race condition during session initialization — this is unrelated to the PR's changes (which only touch auth code).

Root Cause: TestTimeout::test_timeout_client_timeout_does_not_override_tool_call_timeout_if_lower in tests/client/test_sse.py creates a Client with timeout=0.1 (100ms). This timeout is passed as read_timeout_seconds to ClientSession and is also applied as the httpx connection timeout in SSETransport.connect_session(). On a loaded CI runner, the SSE connection + MCP initialize handshake takes longer than 100ms, so the session throws a McpError: Timed out while waiting for response to ClientRequest. Waited 0.1 seconds. during __aenter__ — before any tool call is ever made. The test is designed to verify that a per-tool-call timeout=2 overrides the client-level timeout=0.1, but it never reaches that point.

This test passes on Python 3.13/ubuntu and Python 3.10/windows in this same run, and consistently passes on main. The PR itself only modifies src/fastmcp/server/auth/, tests/server/auth/, and docs — completely unrelated to the SSE client transport.

Suggested Solution: This is a pre-existing flaky test. Two options:

  1. Re-run the workflow — this is the simplest fix since the failure is intermittent and the PR is unrelated. The test should pass on a retry.

  2. Fix the test's fragility in tests/client/test_sse.py (separate PR): the test should use a larger init_timeout or separate the connection phase from the timeout-under-test. For example, connect first (without a tight timeout), then apply the short timeout only to the tool call:

    # In tests/client/test_sse.py lines 188-200
    # Connect without short timeout, then verify per-call timeout takes precedence
    async with Client(transport=SSETransport(sse_server), timeout=0.1, init_timeout=5) as client:
        await client.call_tool("sleep", {"seconds": 0.03}, timeout=2)
Detailed Analysis

Failing test: tests/client/test_sse.py::TestTimeout::test_timeout_client_timeout_does_not_override_tool_call_timeout_if_lower

Only failing job: Tests: Python 3.10 on ubuntu-latest (jobs on Python 3.13/ubuntu and Python 3.10/windows both pass)

Error:

mcp.shared.exceptions.McpError: Timed out while waiting for response to ClientRequest. Waited 0.1 seconds.

Stack trace:

tests/client/test_sse.py:196: in test_...
  async with Client(transport=SSETransport(sse_server), timeout=0.1) as client:
src/fastmcp/client/client.py:482: in __aenter__
  return await self._connect()
src/fastmcp/client/client.py:635: in _session_runner
  await stack.enter_async_context(self._context_manager())
src/fastmcp/client/transports/sse.py:94: in connect_session
  # -> ClientSession.initialize() called here
.venv/lib/python3.10/site-packages/mcp/shared/session.py:294: McpError

Timeout flow: Client(timeout=0.1) stores timedelta(seconds=0.1) as session_kwargs["read_timeout_seconds"]. In SSETransport.connect_session(), this is also set as client_kwargs["timeout"] (httpx connection timeout). mcp.client.session.ClientSession also uses _session_read_timeout_seconds=timedelta(0.1) for each request wait. The MCP initialize handshake exceeds 100ms on the loaded Python 3.10 CI runner.

Confirmation it's a flaky/pre-existing issue: The same test suite passes on main at SHA 610551c7b610e8f7b9367672c42acc80a2b12038 (run 22524362530) and is unaffected by this PR's changes.

Related Files
  • tests/client/test_sse.py (lines 188-200) — The flaky test. The TestTimeout class is already skipif win32. The timeout=0.1 is too tight for the full SSE connection + MCP initialize round-trip on a loaded CI machine.
  • src/fastmcp/client/client.py (line 291) — timeout is stored as session_kwargs["read_timeout_seconds"], applied to all requests including the init handshake.
  • src/fastmcp/client/transports/sse.py (lines 80-84) — read_timeout_seconds is also used as the httpx timeout, affecting the SSE connection itself.
  • src/fastmcp/server/auth/auth.py, src/fastmcp/server/auth/__init__.py, tests/server/auth/test_multi_auth.py — The PR's actual changes; entirely unrelated to the failing test.

Analysis by Marvin (Claude Code)

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9e8a1f41d4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +550 to +553
def get_routes(self, mcp_path: str | None = None) -> list[Route]:
"""Delegate route creation to the server."""
if self.server is not None:
return self.server.get_routes(mcp_path)
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 Badge Delegate well-known route generation to wrapped server

MultiAuth only delegates get_routes, so get_well_known_routes() falls back to AuthProvider’s generic filter logic instead of the wrapped server’s specialized implementation. For OAuth-backed servers this drops behavior from OAuthProvider.get_well_known_routes (RFC 8414 path-aware discovery), so a path-based issuer like https://api.example.com/api can expose /.well-known/oauth-authorization-server instead of /.well-known/oauth-authorization-server/api when callers mount auth.get_well_known_routes(...) at root. That breaks discovery in mounted deployments using MultiAuth with OAuthProxy/OAuthProvider.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 58902fc193

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/fastmcp/server/auth/auth.py Outdated
Comment on lines +522 to +523
effective_scopes = required_scopes or (
server.required_scopes if server else None
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 Badge Honor explicit empty required_scopes override

MultiAuth computes effective_scopes with required_scopes or (...), so passing required_scopes=[] is treated as falsey and silently falls back to server.required_scopes. That makes it impossible to clear inherited scope requirements, even though the constructor documents required_scopes as an override. Because HTTP auth enforcement reads auth.required_scopes from the wrapper, callers can still get scope failures after explicitly requesting no required scopes.

Useful? React with 👍 / 👎.

Comment on lines +550 to +553
def get_routes(self, mcp_path: str | None = None) -> list[Route]:
"""Delegate route creation to the server."""
if self.server is not None:
return self.server.get_routes(mcp_path)
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 Badge Keep delegated auth routes aligned with MultiAuth base_url

When base_url is overridden on MultiAuth, route generation is still delegated directly to self.server.get_routes(mcp_path), which uses the wrapped server’s own base_url to build OAuth/protected-resource metadata. At the same time, the HTTP layer uses the wrapper’s base_url for resource metadata headers, so differing values produce inconsistent discovery URLs in proxied/mounted deployments. If base_url is intended to override the server default, this delegation path leaves discovery metadata out of sync.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0e084029a9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +552 to +555
def get_routes(self, mcp_path: str | None = None) -> list[Route]:
"""Delegate route creation to the server."""
if self.server is not None:
return self.server.get_routes(mcp_path)
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 Badge Propagate required_scopes override to delegated metadata routes

MultiAuth accepts a required_scopes override, and the HTTP layer enforces auth.required_scopes, but route generation is delegated directly to the wrapped server here. That means OAuth/protected-resource metadata still comes from the wrapped provider’s own scope settings, so an override like required_scopes=[] can remove runtime scope enforcement while still advertising inherited scopes in /.well-known responses. In deployments that rely on discovery metadata, clients will request the wrong scopes or treat the resource as stricter than it actually is.

Useful? React with 👍 / 👎.

jlowin added 2 commits March 1, 2026 13:32
…verage

- verify_token now catches exceptions from individual sources and
  continues to the next, so one broken verifier can't take down the
  whole chain
- set_mcp_path propagates to verifiers, not just the server
- Fix jwks_url→jwks_uri typo in class docstring
- Add tests for raising verifiers, valid-token HTTP acceptance,
  and set_mcp_path propagation
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ff6fabb6d1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +523 to +526
required_scopes
if required_scopes is not None
else (server.required_scopes if server else None)
)
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 Badge Propagate scope override to wrapped auth sources

MultiAuth computes an overridden required_scopes value for itself, but the wrapped server/verifiers keep their original scope requirements. Because verify_token delegates to each source's own verifier logic, built-ins like RemoteAuthProvider + JWTVerifier can still reject tokens for missing inherited scopes even when MultiAuth(required_scopes=[]) is set, which produces unexpected 401s and makes the documented override ineffective for those sources.

Useful? React with 👍 / 👎.

@jlowin
Copy link
Copy Markdown
Member Author

jlowin commented Mar 1, 2026

gtg

@joar
Copy link
Copy Markdown

joar commented Mar 2, 2026

It would be nice to provide a mechanism to see which verifier out of [server] + verifiers the current AuthenticatedUser belongs to, I guess that can be worked around by comparing metadata in the tokens.

Something that's missing for my use case as well is the ability to have simultaneous authentication of the agent that is forwarding the request, as well as the end-user.

For example:

  • I would like to deny tools/call by the agent.
  • The end-user would be allowed to do anything FastMCP supports.

with this MultiAuth, I would have an MCP middleware, but then I would have to compare what I would expect to see in a JWT for the agent, with what I would expect to see in a JWT for an end-user, and deny tool calls based on that.

and also, I would only be able to authenticate either the agent or the end-user.

I have a hand-crafted application-code solution that solves the simultaneous auth of agent and end-user by separating the tokens into different headers, I also have an upstream WIP that attempts to do the same: https://github.com/PrefectHQ/fastmcp/compare/main...joar:fastmcp:multi-auth?expand=1

The requirement to list tools before end-user is authenticated is a common source of friction between agent frameworks (ADK, langchain) and the MCP standard, adding a separate Service-Authorization header in the agent with e.g., a Google Service Account OIDC JWT, and allowing tools/list on the MCP side given that the Service-Authorization JWT is valid is a solution to that problem.

@jlowin
Copy link
Copy Markdown
Member Author

jlowin commented Mar 2, 2026

@joar thanks for the detailed writeup! I took a look at your branch — the MultiHeaderAuthProvider approach is clever, and it's nice to see it working through the existing get_middleware() extension point without needing SDK changes.

That said, I think this is fighting against the MCP spec in a way that's hard to support at a framework level. MCP doesn't distinguish between list and execute authorization, and maintaining that split (where a client can discover tools it can't call) is something we actively try to avoid in FastMCP. Your require_user_for_execution check is a reasonable workaround, but it's inherently application-specific policy.

I think MultiAuth (multiple verifiers for a single identity) and what you're building (multiple simultaneous identities with different authorization policies) are different enough that they don't belong in the same abstraction. For now I'd suggest keeping your multi-header approach as application-level customization — the extension points are there for exactly this kind of thing.

Re: knowing which verifier authenticated the user — happy to consider that separately.

@jlowin jlowin added enhancement Improvement to existing functionality. For issues and smaller PR improvements. and removed feature Major new functionality. Reserved for 2-4 significant PRs per release. Not for issues. labels Mar 2, 2026
@jlowin jlowin merged commit 33a69d7 into main Mar 2, 2026
7 checks passed
@jlowin jlowin deleted the claude/review-issue-3035-E6W3s branch March 2, 2026 17:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auth Related to authentication (Bearer, JWT, OAuth, WorkOS) for client or server. enhancement Improvement to existing functionality. For issues and smaller PR improvements. server Related to FastMCP server implementation or server-side functionality.

Projects

None yet

3 participants