diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx
index 73c65b091..1a67dea65 100644
--- a/docs/servers/auth/oauth-proxy.mdx
+++ b/docs/servers/auth/oauth-proxy.mdx
@@ -117,6 +117,12 @@ mcp = FastMCP(name="My Server", auth=auth)
This URL is used to construct OAuth callback URLs and operational endpoints. When mounting under a path prefix, include that prefix in `base_url`. Use `issuer_url` separately to specify where auth server metadata is located (typically at root level).
+
+ Optional public base URL for the protected resource metadata and token audience.
+
+ Use this when your OAuth callbacks and operational endpoints need to live under one public URL, but the protected MCP resource should be advertised under another. FastMCP will still append the MCP mount path (for example, `/mcp`) to this base URL.
+
+
Path for OAuth callbacks. Must match the redirect URI configured in your OAuth
application
diff --git a/docs/servers/auth/oidc-proxy.mdx b/docs/servers/auth/oidc-proxy.mdx
index 73ab43e73..d811d8583 100644
--- a/docs/servers/auth/oidc-proxy.mdx
+++ b/docs/servers/auth/oidc-proxy.mdx
@@ -79,6 +79,12 @@ mcp = FastMCP(name="My Server", auth=auth)
Public URL of your FastMCP server (e.g., `https://your-server.com`)
+
+ Optional public base URL for the protected resource metadata and token audience.
+
+ Use this when your OAuth callbacks and operational endpoints need to live under one public URL, but the protected MCP resource should be advertised under another. FastMCP will still append the MCP mount path (for example, `/mcp`) to this base URL.
+
+
Strict flag for configuration validation. When True, requires all OIDC
mandatory fields.
diff --git a/docs/v2/servers/auth/oauth-proxy.mdx b/docs/v2/servers/auth/oauth-proxy.mdx
index b9bc03430..c3c2e25c5 100644
--- a/docs/v2/servers/auth/oauth-proxy.mdx
+++ b/docs/v2/servers/auth/oauth-proxy.mdx
@@ -115,6 +115,12 @@ mcp = FastMCP(name="My Server", auth=auth)
This URL is used to construct OAuth callback URLs and operational endpoints. When mounting under a path prefix, include that prefix in `base_url`. Use `issuer_url` separately to specify where auth server metadata is located (typically at root level).
+
+ Optional public base URL for the protected resource metadata and token audience.
+
+ Use this when your OAuth callbacks and operational endpoints need to live under one public URL, but the protected MCP resource should be advertised under another. FastMCP will still append the MCP mount path (for example, `/mcp`) to this base URL.
+
+
Path for OAuth callbacks. Must match the redirect URI configured in your OAuth
application
diff --git a/docs/v2/servers/auth/oidc-proxy.mdx b/docs/v2/servers/auth/oidc-proxy.mdx
index 0b3a21d71..a8a97a8de 100644
--- a/docs/v2/servers/auth/oidc-proxy.mdx
+++ b/docs/v2/servers/auth/oidc-proxy.mdx
@@ -79,6 +79,12 @@ mcp = FastMCP(name="My Server", auth=auth)
Public URL of your FastMCP server (e.g., `https://your-server.com`)
+
+ Optional public base URL for the protected resource metadata and token audience.
+
+ Use this when your OAuth callbacks and operational endpoints need to live under one public URL, but the protected MCP resource should be advertised under another. FastMCP will still append the MCP mount path (for example, `/mcp`) to this base URL.
+
+
Strict flag for configuration validation. When True, requires all OIDC
mandatory fields.
diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py
index 3903d513f..c060c0fa6 100644
--- a/src/fastmcp/server/auth/auth.py
+++ b/src/fastmcp/server/auth/auth.py
@@ -217,6 +217,7 @@ class AuthProvider(TokenVerifierProtocol):
self,
base_url: AnyHttpUrl | str | None = None,
required_scopes: list[str] | None = None,
+ resource_base_url: AnyHttpUrl | str | None = None,
):
"""
Initialize the auth provider.
@@ -224,11 +225,21 @@ class AuthProvider(TokenVerifierProtocol):
Args:
base_url: The base URL of this server (e.g., http://localhost:8000).
This is used for constructing .well-known endpoints and OAuth metadata.
+ resource_base_url: Optional public base URL for the protected resource.
+ When provided, the resource URL advertised in protected resource
+ metadata (RFC 9728) is derived from this URL instead of ``base_url``,
+ while operational OAuth routes remain rooted at ``base_url``.
+ Providers that mint their own downstream tokens (e.g. ``OAuthProxy``)
+ also use this as the minted token audience. Upstream token audience
+ validation is configured separately on the token verifier.
required_scopes: List of OAuth scopes required for all requests.
"""
if isinstance(base_url, str):
base_url = AnyHttpUrl(base_url)
+ if isinstance(resource_base_url, str):
+ resource_base_url = AnyHttpUrl(resource_base_url)
self.base_url = base_url
+ self.resource_base_url = resource_base_url
self.required_scopes = required_scopes or []
self._mcp_path: str | None = None
self._resource_url: AnyHttpUrl | None = None
@@ -332,20 +343,24 @@ class AuthProvider(TokenVerifierProtocol):
def _get_resource_url(self, path: str | None = None) -> AnyHttpUrl | None:
"""Get the actual resource URL being protected.
+ Uses ``resource_base_url`` if set; otherwise falls back to
+ ``base_url``.
+
Args:
path: The path where the resource endpoint is mounted (e.g., "/mcp")
Returns:
The full URL of the protected resource
"""
- if self.base_url is None:
+ resource_base_url = self.resource_base_url or self.base_url
+ if resource_base_url is None:
return None
if path:
- prefix = str(self.base_url).rstrip("/")
+ prefix = str(resource_base_url).rstrip("/")
suffix = path.lstrip("/")
return AnyHttpUrl(f"{prefix}/{suffix}")
- return self.base_url
+ return resource_base_url
class TokenVerifier(AuthProvider):
@@ -359,15 +374,25 @@ class TokenVerifier(AuthProvider):
self,
base_url: AnyHttpUrl | str | None = None,
required_scopes: list[str] | None = None,
+ resource_base_url: AnyHttpUrl | str | None = None,
):
"""
Initialize the token verifier.
Args:
base_url: The base URL of this server
+ resource_base_url: Optional public base URL for the protected resource.
+ When provided, the resource URL advertised in protected resource
+ metadata is derived from this URL instead of ``base_url``. Does not
+ configure upstream token audience validation — set ``audience`` on
+ your verifier to match.
required_scopes: Scopes that are required for all requests
"""
- super().__init__(base_url=base_url, required_scopes=required_scopes)
+ super().__init__(
+ base_url=base_url,
+ resource_base_url=resource_base_url,
+ required_scopes=required_scopes,
+ )
@property
def scopes_supported(self) -> list[str]:
@@ -406,6 +431,7 @@ class RemoteAuthProvider(AuthProvider):
authorization_servers: list[AnyHttpUrl],
base_url: AnyHttpUrl | str,
scopes_supported: list[str] | None = None,
+ resource_base_url: AnyHttpUrl | str | None = None,
resource_name: str | None = None,
resource_documentation: AnyHttpUrl | None = None,
):
@@ -415,6 +441,12 @@ class RemoteAuthProvider(AuthProvider):
token_verifier: TokenVerifier instance for token validation
authorization_servers: List of authorization servers that issue valid tokens
base_url: The base URL of this server
+ resource_base_url: Optional public base URL for the protected resource.
+ When provided, the resource URL advertised in protected resource
+ metadata is derived from this URL instead of ``base_url``. Does not
+ configure the token verifier's audience — set ``audience`` on the
+ verifier to match if you want validated tokens bound to the same
+ resource.
scopes_supported: Scopes to advertise in OAuth metadata. If None,
uses the token verifier's scopes_supported property. Use this
when the scopes clients request differ from the scopes that
@@ -424,6 +456,7 @@ class RemoteAuthProvider(AuthProvider):
"""
super().__init__(
base_url=base_url,
+ resource_base_url=resource_base_url,
required_scopes=token_verifier.required_scopes,
)
self.token_verifier = token_verifier
@@ -497,6 +530,7 @@ class MultiAuth(AuthProvider):
server: AuthProvider | None = None,
verifiers: list[TokenVerifier] | TokenVerifier | None = None,
base_url: AnyHttpUrl | str | None = None,
+ resource_base_url: AnyHttpUrl | str | None = None,
required_scopes: list[str] | None = None,
):
"""Initialize the multi-auth provider.
@@ -507,6 +541,8 @@ class MultiAuth(AuthProvider):
the first verifier tried.
verifiers: One or more token verifiers to try after the server.
base_url: Override the base URL. Defaults to the server's base_url.
+ resource_base_url: Override the protected resource base URL. Defaults
+ to the server's resource_base_url when available.
required_scopes: Override required scopes. Defaults to the server's.
"""
if verifiers is None:
@@ -518,16 +554,29 @@ class MultiAuth(AuthProvider):
raise ValueError("MultiAuth requires at least a server or one verifier")
effective_base_url = base_url or (server.base_url if server else None)
+ effective_resource_base_url = resource_base_url or (
+ server.resource_base_url if server else None
+ )
effective_scopes = (
required_scopes
if required_scopes is not None
else (server.required_scopes if server else None)
)
- super().__init__(base_url=effective_base_url, required_scopes=effective_scopes)
+ super().__init__(
+ base_url=effective_base_url,
+ resource_base_url=effective_resource_base_url,
+ required_scopes=effective_scopes,
+ )
self.server = server
self.verifiers = list(verifiers)
+ # If an explicit resource_base_url override was passed to MultiAuth,
+ # propagate it to the wrapped server so its routes advertise metadata
+ # consistent with the outer auth challenge URL.
+ if resource_base_url is not None and self.server is not None:
+ self.server.resource_base_url = self.resource_base_url
+
self._sources: list[AuthProvider] = []
if self.server is not None:
self._sources.append(self.server)
@@ -593,6 +642,7 @@ class OAuthProvider(
self,
*,
base_url: AnyHttpUrl | str,
+ resource_base_url: AnyHttpUrl | str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
service_documentation_url: AnyHttpUrl | str | None = None,
client_registration_options: ClientRegistrationOptions | None = None,
@@ -604,6 +654,9 @@ class OAuthProvider(
Args:
base_url: The public URL of this FastMCP server
+ resource_base_url: Optional public base URL for the protected resource.
+ When provided, the protected resource metadata and token audience are
+ derived from this URL instead of ``base_url``.
issuer_url: The issuer URL for OAuth metadata (defaults to base_url)
service_documentation_url: The URL of the service documentation.
client_registration_options: The client registration options.
@@ -611,7 +664,11 @@ class OAuthProvider(
required_scopes: Scopes that are required for all requests.
"""
- super().__init__(base_url=base_url, required_scopes=required_scopes)
+ super().__init__(
+ base_url=base_url,
+ resource_base_url=resource_base_url,
+ required_scopes=required_scopes,
+ )
if issuer_url is None:
self.issuer_url = self.base_url
diff --git a/src/fastmcp/server/auth/oauth_proxy/proxy.py b/src/fastmcp/server/auth/oauth_proxy/proxy.py
index 206314c61..1b0baead2 100644
--- a/src/fastmcp/server/auth/oauth_proxy/proxy.py
+++ b/src/fastmcp/server/auth/oauth_proxy/proxy.py
@@ -240,6 +240,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
token_verifier: TokenVerifier,
# FastMCP server configuration
base_url: AnyHttpUrl | str,
+ resource_base_url: AnyHttpUrl | str | None = None,
redirect_path: str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
service_documentation_url: AnyHttpUrl | str | None = None,
@@ -281,6 +282,8 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
token_verifier: Token verifier for validating access tokens
base_url: Public URL of the server that exposes this FastMCP server; redirect path is
relative to this URL
+ resource_base_url: Optional public base URL for the protected resource metadata
+ and token audience. Defaults to ``base_url``.
redirect_path: Redirect path configured in upstream OAuth app (defaults to "/auth/callback")
issuer_url: Issuer URL for OAuth metadata (defaults to base_url)
service_documentation_url: Optional service documentation URL
@@ -343,6 +346,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
super().__init__(
base_url=base_url,
+ resource_base_url=resource_base_url,
issuer_url=issuer_url,
service_documentation_url=service_documentation_url,
client_registration_options=client_registration_options,
diff --git a/src/fastmcp/server/auth/oidc_proxy.py b/src/fastmcp/server/auth/oidc_proxy.py
index ebf048c5a..08c746f0b 100644
--- a/src/fastmcp/server/auth/oidc_proxy.py
+++ b/src/fastmcp/server/auth/oidc_proxy.py
@@ -214,6 +214,7 @@ class OIDCProxy(OAuthProxy):
verify_id_token: bool = False,
# FastMCP server configuration
base_url: AnyHttpUrl | str,
+ resource_base_url: AnyHttpUrl | str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
redirect_path: str | None = None,
# Client configuration
@@ -255,6 +256,8 @@ class OIDCProxy(OAuthProxy):
Useful for providers that issue opaque (non-JWT) access tokens, since the
id_token is always a standard JWT verifiable via the provider's JWKS.
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
+ resource_base_url: Optional public base URL for the protected resource metadata
+ and token audience. Defaults to ``base_url``.
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
to avoid 404s during discovery when mounting under a path.
redirect_path: Redirect path configured in upstream OAuth app (defaults to "/auth/callback")
@@ -370,6 +373,7 @@ class OIDCProxy(OAuthProxy):
"upstream_revocation_endpoint": revocation_endpoint,
"token_verifier": token_verifier,
"base_url": base_url,
+ "resource_base_url": resource_base_url,
"issuer_url": issuer_url or base_url,
"service_documentation_url": self.oidc_config.service_documentation,
"allowed_client_redirect_uris": allowed_client_redirect_uris,
diff --git a/src/fastmcp/server/auth/providers/auth0.py b/src/fastmcp/server/auth/providers/auth0.py
index 5b1017c6a..601f314ef 100644
--- a/src/fastmcp/server/auth/providers/auth0.py
+++ b/src/fastmcp/server/auth/providers/auth0.py
@@ -65,6 +65,7 @@ class Auth0Provider(OIDCProxy):
client_secret: str,
audience: str,
base_url: AnyHttpUrl | str,
+ resource_base_url: AnyHttpUrl | str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
required_scopes: list[str] | None = None,
redirect_path: str | None = None,
@@ -83,6 +84,8 @@ class Auth0Provider(OIDCProxy):
client_secret: Auth0 application client secret
audience: Auth0 API audience
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
+ resource_base_url: Optional public base URL for the protected resource metadata
+ and token audience. Defaults to ``base_url``.
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
to avoid 404s during discovery when mounting under a path.
required_scopes: Required Auth0 scopes (defaults to ["openid"])
@@ -113,6 +116,7 @@ class Auth0Provider(OIDCProxy):
client_secret=client_secret,
audience=audience,
base_url=base_url,
+ resource_base_url=resource_base_url,
issuer_url=issuer_url,
redirect_path=redirect_path,
required_scopes=auth0_required_scopes,
diff --git a/src/fastmcp/server/auth/providers/aws.py b/src/fastmcp/server/auth/providers/aws.py
index aa654e966..6f2cfcd56 100644
--- a/src/fastmcp/server/auth/providers/aws.py
+++ b/src/fastmcp/server/auth/providers/aws.py
@@ -125,6 +125,7 @@ class AWSCognitoProvider(OIDCProxy):
client_id: str,
client_secret: str,
base_url: AnyHttpUrl | str,
+ resource_base_url: AnyHttpUrl | str | None = None,
aws_region: str = "eu-central-1",
issuer_url: AnyHttpUrl | str | None = None,
redirect_path: str = "/auth/callback",
@@ -143,6 +144,8 @@ class AWSCognitoProvider(OIDCProxy):
client_id: Cognito app client ID
client_secret: Cognito app client secret
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
+ resource_base_url: Optional public base URL for the protected resource metadata
+ and token audience. Defaults to ``base_url``.
aws_region: AWS region where your User Pool is located (defaults to "eu-central-1")
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
to avoid 404s during discovery when mounting under a path.
@@ -184,6 +187,7 @@ class AWSCognitoProvider(OIDCProxy):
algorithm="RS256",
required_scopes=required_scopes_final,
base_url=base_url,
+ resource_base_url=resource_base_url,
issuer_url=issuer_url,
redirect_path=redirect_path,
allowed_client_redirect_uris=allowed_client_redirect_uris,
diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py
index e2abb6125..d336e84ac 100644
--- a/src/fastmcp/server/auth/providers/azure.py
+++ b/src/fastmcp/server/auth/providers/azure.py
@@ -24,6 +24,7 @@ if TYPE_CHECKING:
from azure.identity.aio import OnBehalfOfCredential
from mcp.server.auth.provider import AuthorizationParams
from mcp.shared.auth import OAuthClientInformationFull
+ from pydantic import AnyHttpUrl
from fastmcp.server.auth.auth import AuthProvider
@@ -103,6 +104,7 @@ class AzureProvider(OAuthProxy):
tenant_id: str,
required_scopes: list[str],
base_url: str,
+ resource_base_url: AnyHttpUrl | str | None = None,
identifier_uri: str | None = None,
issuer_url: str | None = None,
redirect_path: str | None = None,
@@ -131,6 +133,8 @@ class AzureProvider(OAuthProxy):
Example: identifier_uri="api://my-api" + required_scopes=["read"]
→ tokens validated for "api://my-api/read"
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
+ resource_base_url: Optional public base URL for the protected resource metadata
+ and token audience. Defaults to ``base_url``.
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
to avoid 404s during discovery when mounting under a path.
redirect_path: Redirect path configured in Azure App registration (defaults to "/auth/callback")
@@ -242,6 +246,7 @@ class AzureProvider(OAuthProxy):
upstream_client_secret=client_secret,
token_verifier=token_verifier,
base_url=base_url,
+ resource_base_url=resource_base_url,
redirect_path=redirect_path,
issuer_url=issuer_url or base_url, # Default to base_url if not specified
allowed_client_redirect_uris=allowed_client_redirect_uris,
diff --git a/src/fastmcp/server/auth/providers/clerk.py b/src/fastmcp/server/auth/providers/clerk.py
index 409a065b3..b465faf39 100644
--- a/src/fastmcp/server/auth/providers/clerk.py
+++ b/src/fastmcp/server/auth/providers/clerk.py
@@ -277,6 +277,7 @@ class ClerkProvider(OAuthProxy):
client_id: str,
client_secret: str | None = None,
base_url: AnyHttpUrl | str,
+ resource_base_url: AnyHttpUrl | str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
redirect_path: str | None = None,
required_scopes: list[str] | None = None,
@@ -301,6 +302,8 @@ class ClerkProvider(OAuthProxy):
client_secret: Clerk OAuth application client secret.
Optional for PKCE public clients. When omitted, jwt_signing_key must be provided.
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
+ resource_base_url: Optional public base URL for the protected resource metadata
+ and token audience. Defaults to ``base_url``.
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
to avoid 404s during discovery when mounting under a path.
redirect_path: Redirect path configured in Clerk OAuth app (defaults to "/auth/callback")
@@ -364,6 +367,7 @@ class ClerkProvider(OAuthProxy):
upstream_client_secret=client_secret,
token_verifier=token_verifier,
base_url=base_url,
+ resource_base_url=resource_base_url,
redirect_path=redirect_path,
issuer_url=issuer_url or base_url,
allowed_client_redirect_uris=allowed_client_redirect_uris,
diff --git a/src/fastmcp/server/auth/providers/discord.py b/src/fastmcp/server/auth/providers/discord.py
index d646743f9..333001cfa 100644
--- a/src/fastmcp/server/auth/providers/discord.py
+++ b/src/fastmcp/server/auth/providers/discord.py
@@ -196,6 +196,7 @@ class DiscordProvider(OAuthProxy):
client_id: str,
client_secret: str,
base_url: AnyHttpUrl | str,
+ resource_base_url: AnyHttpUrl | str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
redirect_path: str | None = None,
required_scopes: list[str] | None = None,
@@ -215,6 +216,8 @@ class DiscordProvider(OAuthProxy):
client_id: Discord OAuth client ID (e.g., "123456789")
client_secret: Discord OAuth client secret (e.g., "S....")
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
+ resource_base_url: Optional public base URL for the protected resource metadata
+ and token audience. Defaults to ``base_url``.
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
to avoid 404s during discovery when mounting under a path.
redirect_path: Redirect path configured in Discord OAuth app (defaults to "/auth/callback")
@@ -266,6 +269,7 @@ class DiscordProvider(OAuthProxy):
upstream_client_secret=client_secret,
token_verifier=token_verifier,
base_url=base_url,
+ resource_base_url=resource_base_url,
redirect_path=redirect_path,
issuer_url=issuer_url or base_url, # Default to base_url if not specified
allowed_client_redirect_uris=allowed_client_redirect_uris,
diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py
index b8f5a16e2..0655a8bd5 100644
--- a/src/fastmcp/server/auth/providers/github.py
+++ b/src/fastmcp/server/auth/providers/github.py
@@ -209,6 +209,7 @@ class GitHubProvider(OAuthProxy):
client_id: str,
client_secret: str,
base_url: AnyHttpUrl | str,
+ resource_base_url: AnyHttpUrl | str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
redirect_path: str | None = None,
required_scopes: list[str] | None = None,
@@ -230,6 +231,8 @@ class GitHubProvider(OAuthProxy):
client_id: GitHub OAuth app client ID (e.g., "Ov23li...")
client_secret: GitHub OAuth app client secret
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
+ resource_base_url: Optional public base URL for the protected resource metadata
+ and token audience. Defaults to ``base_url``.
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
to avoid 404s during discovery when mounting under a path.
redirect_path: Redirect path configured in GitHub OAuth app (defaults to "/auth/callback")
@@ -281,6 +284,7 @@ class GitHubProvider(OAuthProxy):
upstream_client_secret=client_secret,
token_verifier=token_verifier,
base_url=base_url,
+ resource_base_url=resource_base_url,
redirect_path=redirect_path,
issuer_url=issuer_url or base_url, # Default to base_url if not specified
allowed_client_redirect_uris=allowed_client_redirect_uris,
diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py
index 28c31a0d8..219e6c0db 100644
--- a/src/fastmcp/server/auth/providers/google.py
+++ b/src/fastmcp/server/auth/providers/google.py
@@ -235,6 +235,7 @@ class GoogleProvider(OAuthProxy):
client_id: str,
client_secret: str | None = None,
base_url: AnyHttpUrl | str,
+ resource_base_url: AnyHttpUrl | str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
redirect_path: str | None = None,
required_scopes: list[str] | None = None,
@@ -258,6 +259,8 @@ class GoogleProvider(OAuthProxy):
Optional for PKCE public clients (e.g., native apps). When omitted,
jwt_signing_key must be provided.
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
+ resource_base_url: Optional public base URL for the protected resource metadata
+ and token audience. Defaults to ``base_url``.
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
to avoid 404s during discovery when mounting under a path.
redirect_path: Redirect path configured in Google OAuth app (defaults to "/auth/callback")
@@ -341,6 +344,7 @@ class GoogleProvider(OAuthProxy):
upstream_client_secret=client_secret,
token_verifier=token_verifier,
base_url=base_url,
+ resource_base_url=resource_base_url,
redirect_path=redirect_path,
issuer_url=issuer_url or base_url, # Default to base_url if not specified
allowed_client_redirect_uris=allowed_client_redirect_uris,
diff --git a/src/fastmcp/server/auth/providers/in_memory.py b/src/fastmcp/server/auth/providers/in_memory.py
index 08a7fc2a1..3ae686dac 100644
--- a/src/fastmcp/server/auth/providers/in_memory.py
+++ b/src/fastmcp/server/auth/providers/in_memory.py
@@ -37,6 +37,7 @@ class InMemoryOAuthProvider(OAuthProvider):
def __init__(
self,
base_url: AnyHttpUrl | str | None = None,
+ resource_base_url: AnyHttpUrl | str | None = None,
service_documentation_url: AnyHttpUrl | str | None = None,
client_registration_options: ClientRegistrationOptions | None = None,
revocation_options: RevocationOptions | None = None,
@@ -44,6 +45,7 @@ class InMemoryOAuthProvider(OAuthProvider):
):
super().__init__(
base_url=base_url or "http://fastmcp.example.com",
+ resource_base_url=resource_base_url,
service_documentation_url=service_documentation_url,
client_registration_options=client_registration_options,
revocation_options=revocation_options,
diff --git a/src/fastmcp/server/auth/providers/oci.py b/src/fastmcp/server/auth/providers/oci.py
index 98011e4ed..07bad2a41 100644
--- a/src/fastmcp/server/auth/providers/oci.py
+++ b/src/fastmcp/server/auth/providers/oci.py
@@ -123,6 +123,7 @@ class OCIProvider(OIDCProxy):
client_id: str,
client_secret: str,
base_url: AnyHttpUrl | str,
+ resource_base_url: AnyHttpUrl | str | None = None,
audience: str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
required_scopes: list[str] | None = None,
@@ -141,6 +142,8 @@ class OCIProvider(OIDCProxy):
client_id: OCI IAM Domain Integrated Application client id
client_secret: OCI Integrated Application client secret
base_url: Public URL where OIDC endpoints will be accessible (includes any mount path)
+ resource_base_url: Optional public base URL for the protected resource metadata
+ and token audience. Defaults to ``base_url``.
audience: OCI API audience (optional)
issuer_url: Issuer URL for OCI IAM Domain metadata. This will override issuer URL from the discovery URL.
required_scopes: Required OCI scopes (defaults to ["openid"])
@@ -158,6 +161,7 @@ class OCIProvider(OIDCProxy):
client_secret=client_secret,
audience=audience,
base_url=base_url,
+ resource_base_url=resource_base_url,
issuer_url=issuer_url,
redirect_path=redirect_path,
required_scopes=oci_required_scopes,
diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py
index 85dd8feca..a29e331fb 100644
--- a/src/fastmcp/server/auth/providers/workos.py
+++ b/src/fastmcp/server/auth/providers/workos.py
@@ -164,6 +164,7 @@ class WorkOSProvider(OAuthProxy):
client_secret: str,
authkit_domain: str,
base_url: AnyHttpUrl | str,
+ resource_base_url: AnyHttpUrl | str | None = None,
issuer_url: AnyHttpUrl | str | None = None,
redirect_path: str | None = None,
required_scopes: list[str] | None = None,
@@ -184,6 +185,8 @@ class WorkOSProvider(OAuthProxy):
client_secret: WorkOS client secret
authkit_domain: Your WorkOS AuthKit domain (e.g., "https://your-app.authkit.app")
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
+ resource_base_url: Optional public base URL for the protected resource metadata
+ and token audience. Defaults to ``base_url``.
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
to avoid 404s during discovery when mounting under a path.
redirect_path: Redirect path configured in WorkOS (defaults to "/auth/callback")
@@ -234,6 +237,7 @@ class WorkOSProvider(OAuthProxy):
upstream_client_secret=client_secret,
token_verifier=token_verifier,
base_url=base_url,
+ resource_base_url=resource_base_url,
redirect_path=redirect_path,
issuer_url=issuer_url or base_url, # Default to base_url if not specified
allowed_client_redirect_uris=allowed_client_redirect_uris,
diff --git a/tests/server/auth/oauth_proxy/test_config.py b/tests/server/auth/oauth_proxy/test_config.py
index 0b88a0ae5..c5e8e3078 100644
--- a/tests/server/auth/oauth_proxy/test_config.py
+++ b/tests/server/auth/oauth_proxy/test_config.py
@@ -410,6 +410,44 @@ class TestResourceURLValidation:
assert proxy.jwt_issuer.audience == "https://proxy.example.com/"
+ def test_set_mcp_path_uses_resource_base_url_for_audience(self, jwt_verifier):
+ """Test that resource_base_url controls the protected resource audience."""
+ proxy = OAuthProxy(
+ upstream_authorization_endpoint="https://oauth.example.com/authorize",
+ upstream_token_endpoint="https://oauth.example.com/token",
+ upstream_client_id="upstream-client",
+ upstream_client_secret="upstream-secret",
+ token_verifier=jwt_verifier,
+ base_url="https://proxy.example.com/oauth",
+ resource_base_url="https://api.example.com",
+ jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
+ )
+
+ proxy.set_mcp_path("/mcp")
+
+ assert proxy.jwt_issuer.issuer == "https://proxy.example.com/oauth"
+ assert proxy.jwt_issuer.audience == "https://api.example.com/mcp"
+
+ def test_set_mcp_path_none_uses_resource_base_url_for_audience(self, jwt_verifier):
+ """Test that resource_base_url is used as audience when mcp_path is None."""
+ proxy = OAuthProxy(
+ upstream_authorization_endpoint="https://oauth.example.com/authorize",
+ upstream_token_endpoint="https://oauth.example.com/token",
+ upstream_client_id="upstream-client",
+ upstream_client_secret="upstream-secret",
+ token_verifier=jwt_verifier,
+ base_url="https://proxy.example.com/oauth",
+ resource_base_url="https://api.example.com",
+ jwt_signing_key="test-secret",
+ client_storage=MemoryStore(),
+ )
+
+ proxy.set_mcp_path(None)
+
+ assert proxy.jwt_issuer.issuer == "https://proxy.example.com/oauth"
+ assert proxy.jwt_issuer.audience == "https://api.example.com/"
+
def test_jwt_issuer_property_raises_if_not_initialized(self, jwt_verifier):
"""Test that jwt_issuer property raises if set_mcp_path not called."""
proxy = OAuthProxy(
diff --git a/tests/server/auth/providers/test_github.py b/tests/server/auth/providers/test_github.py
index 11683f8b6..a0cc4b9cd 100644
--- a/tests/server/auth/providers/test_github.py
+++ b/tests/server/auth/providers/test_github.py
@@ -57,6 +57,23 @@ class TestGitHubProvider:
# The required_scopes should be passed to the token verifier
assert provider._token_validator.required_scopes == ["user"]
+ def test_init_with_resource_base_url(self, memory_storage: MemoryStore):
+ """Test that resource_base_url overrides the advertised protected resource."""
+ provider = GitHubProvider(
+ client_id="test_client",
+ client_secret="test_secret",
+ base_url="https://auth.example.com/proxy",
+ resource_base_url="https://api.example.com",
+ jwt_signing_key="test-secret",
+ client_storage=memory_storage,
+ )
+
+ provider.set_mcp_path("/mcp")
+
+ assert str(provider.base_url) == "https://auth.example.com/proxy"
+ assert str(provider.resource_base_url) == "https://api.example.com/"
+ assert provider.jwt_issuer.audience == "https://api.example.com/mcp"
+
class TestGitHubTokenVerifier:
"""Test GitHubTokenVerifier."""
diff --git a/tests/server/auth/test_auth_provider.py b/tests/server/auth/test_auth_provider.py
index ab6ab36cf..22f98e0ab 100644
--- a/tests/server/auth/test_auth_provider.py
+++ b/tests/server/auth/test_auth_provider.py
@@ -5,13 +5,36 @@ import pytest
from pydantic import AnyHttpUrl
from fastmcp import FastMCP
-from fastmcp.server.auth import RemoteAuthProvider
+from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier
+from fastmcp.server.auth.auth import AccessToken
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier
+class LegacyTokenVerifier(TokenVerifier):
+ """Mimics custom verifiers that still call the old positional super().__init__."""
+
+ def __init__(
+ self,
+ base_url: AnyHttpUrl | str | None = None,
+ required_scopes: list[str] | None = None,
+ ):
+ super().__init__(base_url, required_scopes)
+
+ async def verify_token(self, token: str) -> AccessToken | None:
+ return None
+
+
class TestAuthProviderBase:
"""Test suite for base AuthProvider behaviors that apply to all auth providers."""
+ def test_token_verifier_preserves_legacy_positional_required_scopes(self):
+ """Legacy positional super().__init__(base_url, required_scopes) should keep working."""
+ verifier = LegacyTokenVerifier("https://my-server.com", ["read"])
+
+ assert verifier.base_url == AnyHttpUrl("https://my-server.com/")
+ assert verifier.required_scopes == ["read"]
+ assert verifier.resource_base_url is None
+
@pytest.fixture
def basic_remote_provider(self):
"""Basic RemoteAuthProvider fixture for testing base AuthProvider behaviors."""
diff --git a/tests/server/auth/test_multi_auth.py b/tests/server/auth/test_multi_auth.py
index 369da86a5..727158d1c 100644
--- a/tests/server/auth/test_multi_auth.py
+++ b/tests/server/auth/test_multi_auth.py
@@ -66,6 +66,34 @@ class TestMultiAuthInit:
auth = MultiAuth(server=provider, base_url="https://override.example.com")
assert auth.base_url == AnyHttpUrl("https://override.example.com/")
+ def test_resource_base_url_from_server(self):
+ verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}})
+ provider = RemoteAuthProvider(
+ token_verifier=verifier,
+ authorization_servers=[AnyHttpUrl("https://auth.example.com")],
+ base_url="https://auth.example.com/proxy",
+ resource_base_url="https://api.example.com",
+ )
+ auth = MultiAuth(server=provider)
+ assert auth.resource_base_url == AnyHttpUrl("https://api.example.com/")
+
+ def test_resource_base_url_override(self):
+ verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}})
+ provider = RemoteAuthProvider(
+ token_verifier=verifier,
+ authorization_servers=[AnyHttpUrl("https://auth.example.com")],
+ base_url="https://auth.example.com/proxy",
+ resource_base_url="https://api.example.com",
+ )
+ auth = MultiAuth(
+ server=provider,
+ resource_base_url="https://override.example.com",
+ )
+ assert auth.resource_base_url == AnyHttpUrl("https://override.example.com/")
+ # Override must propagate to the wrapped server so get_routes()
+ # serves metadata consistent with the outer auth challenge URL.
+ assert provider.resource_base_url == AnyHttpUrl("https://override.example.com/")
+
def test_required_scopes_from_server(self):
verifier = StaticTokenVerifier(
tokens={"t": {"client_id": "c", "scopes": ["read"]}},
@@ -328,6 +356,65 @@ class TestMultiAuthIntegration:
data = response.json()
assert data["resource"] == "https://api.example.com/mcp"
+ async def test_multi_auth_uses_server_resource_base_url_in_auth_challenge(self):
+ """Auth challenges should advertise resource metadata from resource_base_url."""
+ verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}})
+ server = RemoteAuthProvider(
+ token_verifier=verifier,
+ authorization_servers=[AnyHttpUrl("https://auth.example.com")],
+ base_url="https://auth.example.com/proxy",
+ resource_base_url="https://api.example.com",
+ )
+
+ auth = MultiAuth(server=server)
+ mcp = FastMCP("test", auth=auth)
+ app = mcp.http_app(path="/mcp")
+
+ async with httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=app),
+ base_url="http://localhost",
+ ) as client:
+ response = await client.get("/mcp")
+ assert response.status_code == 401
+ assert (
+ 'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"'
+ in response.headers["www-authenticate"]
+ )
+
+ async def test_multi_auth_override_propagates_to_served_metadata(self):
+ """Override on MultiAuth must propagate so served metadata matches the challenge."""
+ verifier = StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}})
+ server = RemoteAuthProvider(
+ token_verifier=verifier,
+ authorization_servers=[AnyHttpUrl("https://auth.example.com")],
+ base_url="https://auth.example.com/proxy",
+ )
+
+ auth = MultiAuth(server=server, resource_base_url="https://api.example.com")
+ mcp = FastMCP("test", auth=auth)
+ app = mcp.http_app(path="/mcp")
+
+ async with httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=app),
+ base_url="http://localhost",
+ ) as client:
+ response = await client.get("/mcp")
+ assert response.status_code == 401
+ assert (
+ 'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"'
+ in response.headers["www-authenticate"]
+ )
+
+ metadata_response = await client.get(
+ "/.well-known/oauth-protected-resource/mcp"
+ )
+ assert metadata_response.status_code == 200
+ assert metadata_response.json()["resource"] == "https://api.example.com/mcp"
+ old_path_response = await client.get(
+ "/.well-known/oauth-protected-resource/proxy/mcp"
+ )
+ assert old_path_response.status_code == 404
+
async def test_multi_auth_accepts_valid_verifier_token(self):
"""MultiAuth accepts tokens from verifiers (not just the server).
diff --git a/tests/server/auth/test_remote_auth_provider.py b/tests/server/auth/test_remote_auth_provider.py
index 5d56c5b5c..bc37decf8 100644
--- a/tests/server/auth/test_remote_auth_provider.py
+++ b/tests/server/auth/test_remote_auth_provider.py
@@ -150,6 +150,36 @@ class TestRemoteAuthProvider:
"https://api.example.com/.well-known/oauth-protected-resource/mcp"
)
+ def test_get_resource_url_uses_resource_base_url_when_provided(self, test_tokens):
+ """Test protected resource URLs are derived from resource_base_url when provided."""
+ token_verifier = StaticTokenVerifier(tokens=test_tokens)
+ provider = RemoteAuthProvider(
+ token_verifier=token_verifier,
+ authorization_servers=[AnyHttpUrl("https://auth.example.com")],
+ base_url="https://auth.example.com/proxy",
+ resource_base_url="https://api.example.com",
+ )
+
+ assert provider._get_resource_url("/mcp") == AnyHttpUrl(
+ "https://api.example.com/mcp"
+ )
+
+ def test_init_preserves_legacy_positional_scopes_supported_slot(self, test_tokens):
+ """Legacy positional scopes_supported should not bind to resource_base_url."""
+ token_verifier = StaticTokenVerifier(tokens=test_tokens)
+ provider = RemoteAuthProvider(
+ token_verifier,
+ [AnyHttpUrl("https://auth.example.com")],
+ "https://api.example.com",
+ ["read"],
+ )
+
+ assert provider._scopes_supported == ["read"]
+ assert provider.resource_base_url is None
+ assert provider._get_resource_url("/mcp") == AnyHttpUrl(
+ "https://api.example.com/mcp"
+ )
+
class TestRemoteAuthProviderIntegration:
"""Integration tests for RemoteAuthProvider with FastMCP server."""