Fix RFC 8414 path-aware authorization server metadata discovery (#2533)

* Fix RFC 8414 path-aware authorization server metadata discovery

Override get_well_known_routes() in OAuthProvider to rewrite the
authorization server metadata route to be path-aware based on issuer_url,
matching how protected resource metadata already works.

Closes #2527

* Update readme
This commit is contained in:
Jeremiah Lowin 2025-12-03 19:16:59 -05:00 committed by GitHub
commit 9cade6c8c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 412 additions and 33 deletions

View file

@ -20,24 +20,24 @@ uv run pytest # Run full test suite
## Repository Structure
| Path | Purpose |
| ------------------ | --------------------------------------------------- |
| `src/fastmcp/` | Library source code (Python ≥ 3.10) |
| `├─server/` | Server implementation, `FastMCP`, auth, networking |
| Path | Purpose |
| ------------------ | ----------------------------------------------------------------------------------- |
| `src/fastmcp/` | Library source code (Python ≥ 3.10) |
| `├─server/` | Server implementation, `FastMCP`, auth, networking |
| `│ ├─auth/` | Authentication providers (Google, GitHub, Azure, AWS, WorkOS, Auth0, JWT, and more) |
| `│ └─middleware/` | Error handling, logging, rate limiting |
| `├─client/` | High-level client SDK + transports |
| `│ └─auth/` | Client authentication (Bearer, OAuth) |
| `├─tools/` | Tool implementations + `ToolManager` |
| `├─resources/` | Resources, templates + `ResourceManager` |
| `├─prompts/` | Prompt templates + `PromptManager` |
| `├─cli/` | FastMCP CLI commands (`run`, `dev`, `install`) |
| `├─contrib/` | Community contributions (bulk caller, mixins) |
| `├─experimental/` | Experimental features (sampling handlers) |
| `└─utilities/` | Shared utilities (logging, JSON schema, HTTP) |
| `tests/` | Comprehensive pytest suite with markers |
| `docs/` | Mintlify documentation (published to gofastmcp.com) |
| `examples/` | Runnable demo servers (echo, smart_home, atproto) |
| `│ └─middleware/` | Error handling, logging, rate limiting |
| `├─client/` | High-level client SDK + transports |
| `│ └─auth/` | Client authentication (Bearer, OAuth) |
| `├─tools/` | Tool implementations + `ToolManager` |
| `├─resources/` | Resources, templates + `ResourceManager` |
| `├─prompts/` | Prompt templates + `PromptManager` |
| `├─cli/` | FastMCP CLI commands (`run`, `dev`, `install`) |
| `├─contrib/` | Community contributions (bulk caller, mixins) |
| `├─experimental/` | Experimental features (sampling handlers) |
| `└─utilities/` | Shared utilities (logging, JSON schema, HTTP) |
| `tests/` | Comprehensive pytest suite with markers |
| `docs/` | Mintlify documentation (published to gofastmcp.com) |
| `examples/` | Runnable demo servers (echo, smart_home, atproto) |
## Core MCP Objects
@ -106,6 +106,7 @@ async with Client(transport=StreamableHttpTransport(server_url)) as client:
- Improvements = enhancements (not features) unless specified
- **NEVER** force-push on collaborative repos
- **ALWAYS** run prek before PRs
- **NEVER** create a release, comment on an issue, or open a PR unless specifically instructed to do so.
### Commit Messages and Agent Attribution

View file

@ -328,8 +328,6 @@ OAuth specifications (RFC 8414 and RFC 9728) require discovery metadata to be ac
# Result: /api/api/mcp (double prefix!)
```
3. **Not setting issuer_url when mounting** - Without `issuer_url` set to root level, OAuth discovery will attempt path-scoped discovery first (which will 404), adding unnecessary error logs.
Follow the configuration instructions below to set up mounting correctly.
</Warning>
@ -373,12 +371,15 @@ base_url="http://localhost:8000/api" # Includes mount prefix
mcp_path="/mcp" # Internal MCP path, NOT the mount prefix
```
**`issuer_url`** tells clients where to find discovery metadata. This should point to the root level of your server where well-known routes are mounted:
**`issuer_url`** (optional) controls the authorization server identity for OAuth discovery. Defaults to `base_url`.
```python
issuer_url="http://localhost:8000" # Root level, no prefix
# Usually not needed - just set base_url and it works
issuer_url="http://localhost:8000" # Only if you want root-level discovery
```
When `issuer_url` has a path (either explicitly or by defaulting from `base_url`), FastMCP creates path-aware discovery routes per RFC 8414. For example, if `base_url` is `http://localhost:8000/api`, the authorization server metadata will be at `/.well-known/oauth-authorization-server/api`.
**Key Invariant:** `base_url + mcp_path = actual externally-accessible MCP URL`
Example:
@ -404,14 +405,14 @@ MOUNT_PREFIX = "/api"
MCP_PATH = "/mcp"
```
Create the auth provider with both `issuer_url` and `base_url`:
Create the auth provider with `base_url`:
```python
auth = GitHubProvider(
client_id="your-client-id",
client_secret="your-client-secret",
issuer_url=ROOT_URL, # Discovery metadata at root
base_url=f"{ROOT_URL}{MOUNT_PREFIX}", # Operational endpoints under prefix
# issuer_url defaults to base_url - path-aware discovery works automatically
)
```
@ -445,9 +446,11 @@ This configuration produces the following URL structure:
- MCP endpoint: `http://localhost:8000/api/mcp`
- OAuth authorization: `http://localhost:8000/api/authorize`
- OAuth callback: `http://localhost:8000/api/auth/callback`
- Authorization server metadata: `http://localhost:8000/.well-known/oauth-authorization-server`
- Authorization server metadata: `http://localhost:8000/.well-known/oauth-authorization-server/api`
- Protected resource metadata: `http://localhost:8000/.well-known/oauth-protected-resource/api/mcp`
Both discovery endpoints use path-aware URLs per RFC 8414 and RFC 9728, matching the `base_url` path.
### Complete Example
Here's a complete working example showing all the pieces together:
@ -468,8 +471,8 @@ MCP_PATH = "/mcp"
auth = GitHubProvider(
client_id="your-client-id",
client_secret="your-client-secret",
issuer_url=ROOT_URL,
base_url=f"{ROOT_URL}{MOUNT_PREFIX}",
# issuer_url defaults to base_url - path-aware discovery works automatically
)
# Create MCP server

View file

@ -127,21 +127,24 @@ mcp = FastMCP(name="My Server", auth=auth)
<ParamField body="issuer_url" type="AnyHttpUrl | str | None">
Issuer URL for OAuth authorization server metadata (defaults to `base_url`).
When mounting your MCP server under a path prefix (e.g., `/api`), set this to your root-level URL to avoid 404 logs during OAuth discovery. MCP clients try path-scoped discovery first per RFC 8414, which will fail if your auth server metadata is at the root level.
When `issuer_url` has a path component (either explicitly or by defaulting from `base_url`), FastMCP creates path-aware discovery routes per RFC 8414. For example, if `base_url` is `http://localhost:8000/api`, the authorization server metadata will be at `/.well-known/oauth-authorization-server/api`.
**Example with mounting:**
**Default behavior (recommended for most cases):**
```python
auth = GitHubProvider(
base_url="http://localhost:8000/api", # OAuth endpoints under /api
issuer_url="http://localhost:8000" # Auth server metadata at root
# issuer_url defaults to base_url - path-aware discovery works automatically
)
```
Without `issuer_url`, clients will attempt `/.well-known/oauth-authorization-server/api` (404) before falling back to `/.well-known/oauth-authorization-server` (success). Setting `issuer_url` to the root eliminates the 404 attempt.
**When to use:**
- **Default (`None`)**: Use `base_url` as issuer - simple deployments at root path
- **Root-level URL**: Mounting under a path prefix - avoids 404 logs
**When to set explicitly:**
Set `issuer_url` to root level only if you want multiple MCP servers to share a single discovery endpoint:
```python
auth = GitHubProvider(
base_url="http://localhost:8000/api",
issuer_url="http://localhost:8000" # Shared root-level discovery
)
```
See the [HTTP Deployment guide](/deployment/http#mounting-authenticated-servers) for complete mounting examples.
</ParamField>

View file

@ -0,0 +1,39 @@
# Multi-Provider OAuth Example
This example demonstrates mounting multiple OAuth-protected MCP servers in a single application, each with its own OAuth provider. It showcases RFC 8414 path-aware discovery where each server has its own authorization server metadata endpoint.
## URL Structure
- **GitHub MCP**: `http://localhost:8000/api/mcp/github/mcp`
- **Google MCP**: `http://localhost:8000/api/mcp/google/mcp`
Discovery endpoints (RFC 8414 path-aware):
- **GitHub**: `http://localhost:8000/.well-known/oauth-authorization-server/api/mcp/github`
- **Google**: `http://localhost:8000/.well-known/oauth-authorization-server/api/mcp/google`
## Setup
Set environment variables for both providers:
```bash
export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID="your-github-client-id"
export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET="your-github-client-secret"
export FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID="your-google-client-id"
export FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET="your-google-client-secret"
```
Configure redirect URIs in each provider's developer console (note the `/api/mcp/{provider}` prefix since the servers are mounted):
- GitHub: `http://localhost:8000/api/mcp/github/auth/callback/github`
- Google: `http://localhost:8000/api/mcp/google/auth/callback/google`
## Running
Start the server:
```bash
python server.py
```
Connect with the client:
```bash
python client.py
```

View file

@ -0,0 +1,50 @@
"""Mounted OAuth servers client example for FastMCP.
This example demonstrates connecting to multiple mounted OAuth-protected MCP servers.
To run:
python client.py
"""
import asyncio
from fastmcp.client import Client
GITHUB_URL = "http://127.0.0.1:8000/api/mcp/github/mcp"
GOOGLE_URL = "http://127.0.0.1:8000/api/mcp/google/mcp"
async def main():
# Connect to GitHub server
print("\n--- GitHub Server ---")
try:
async with Client(GITHUB_URL, auth="oauth") as client:
assert await client.ping()
print("✅ Successfully authenticated!")
tools = await client.list_tools()
print(f"🔧 Available tools ({len(tools)}):")
for tool in tools:
print(f" - {tool.name}: {tool.description}")
except Exception as e:
print(f"❌ Authentication failed: {e}")
raise
# Connect to Google server
print("\n--- Google Server ---")
try:
async with Client(GOOGLE_URL, auth="oauth") as client:
assert await client.ping()
print("✅ Successfully authenticated!")
tools = await client.list_tools()
print(f"🔧 Available tools ({len(tools)}):")
for tool in tools:
print(f" - {tool.name}: {tool.description}")
except Exception as e:
print(f"❌ Authentication failed: {e}")
raise
if __name__ == "__main__":
asyncio.run(main())

View file

@ -0,0 +1,121 @@
"""Mounted OAuth servers example for FastMCP.
This example demonstrates mounting multiple OAuth-protected MCP servers in a single
application, each with its own provider. It showcases RFC 8414 path-aware discovery
where each server has its own authorization server metadata endpoint.
URL structure:
- GitHub MCP: http://localhost:8000/api/mcp/github/mcp
- Google MCP: http://localhost:8000/api/mcp/google/mcp
- GitHub discovery: http://localhost:8000/.well-known/oauth-authorization-server/api/mcp/github
- Google discovery: http://localhost:8000/.well-known/oauth-authorization-server/api/mcp/google
Required environment variables:
- FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID: Your GitHub OAuth app client ID
- FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET: Your GitHub OAuth app client secret
- FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID: Your Google OAuth client ID
- FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET: Your Google OAuth client secret
To run:
python server.py
"""
import os
import uvicorn
from starlette.applications import Starlette
from starlette.routing import Mount
from fastmcp import FastMCP
from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.server.auth.providers.google import GoogleProvider
# Configuration
ROOT_URL = "http://localhost:8000"
API_PREFIX = "/api/mcp"
# --- GitHub OAuth Server ---
github_auth = GitHubProvider(
client_id=os.getenv("FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID") or "",
client_secret=os.getenv("FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET") or "",
base_url=f"{ROOT_URL}{API_PREFIX}/github",
redirect_path="/auth/callback/github",
)
github_mcp = FastMCP("GitHub Server", auth=github_auth)
@github_mcp.tool
def github_echo(message: str) -> str:
"""Echo from the GitHub-authenticated server."""
return f"[GitHub] {message}"
@github_mcp.tool
def github_info() -> str:
"""Get info about the GitHub server."""
return "This is the GitHub OAuth protected MCP server"
# --- Google OAuth Server ---
google_auth = GoogleProvider(
client_id=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID") or "",
client_secret=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET") or "",
base_url=f"{ROOT_URL}{API_PREFIX}/google",
redirect_path="/auth/callback/google",
)
google_mcp = FastMCP("Google Server", auth=google_auth)
@google_mcp.tool
def google_echo(message: str) -> str:
"""Echo from the Google-authenticated server."""
return f"[Google] {message}"
@google_mcp.tool
def google_info() -> str:
"""Get info about the Google server."""
return "This is the Google OAuth protected MCP server"
# --- Create ASGI apps ---
github_app = github_mcp.http_app(path="/mcp")
google_app = google_mcp.http_app(path="/mcp")
# Get well-known routes for each provider (path-aware per RFC 8414)
github_well_known = github_auth.get_well_known_routes(mcp_path="/mcp")
google_well_known = google_auth.get_well_known_routes(mcp_path="/mcp")
# --- Combine into single application ---
# Note: Each provider has its own path-aware discovery endpoint:
# - /.well-known/oauth-authorization-server/api/mcp/github
# - /.well-known/oauth-authorization-server/api/mcp/google
app = Starlette(
routes=[
# Well-known routes at root level (path-aware)
*github_well_known,
*google_well_known,
# MCP servers under /api/mcp prefix
Mount(f"{API_PREFIX}/github", app=github_app),
Mount(f"{API_PREFIX}/google", app=google_app),
],
# Use one of the app lifespans (they're functionally equivalent)
lifespan=github_app.lifespan,
)
if __name__ == "__main__":
print("Starting mounted OAuth servers...")
print(f" GitHub MCP: {ROOT_URL}{API_PREFIX}/github/mcp")
print(f" Google MCP: {ROOT_URL}{API_PREFIX}/google/mcp")
print()
print("Discovery endpoints (RFC 8414 path-aware):")
print(
f" GitHub: {ROOT_URL}/.well-known/oauth-authorization-server{API_PREFIX}/github"
)
print(
f" Google: {ROOT_URL}/.well-known/oauth-authorization-server{API_PREFIX}/google"
)
print()
uvicorn.run(app, host="0.0.0.0", port=8000)

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from typing import Any, cast
from urllib.parse import urlparse
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend
@ -397,3 +398,51 @@ class OAuthProvider(
oauth_routes.extend(super().get_routes(mcp_path))
return oauth_routes
def get_well_known_routes(
self,
mcp_path: str | None = None,
) -> list[Route]:
"""Get well-known discovery routes with RFC 8414 path-aware support.
Overrides the base implementation to support path-aware authorization
server metadata discovery per RFC 8414. If issuer_url has a path component,
the authorization server metadata route is adjusted to include that path.
For example, if issuer_url is "http://example.com/api", the discovery
endpoint will be at "/.well-known/oauth-authorization-server/api" instead
of just "/.well-known/oauth-authorization-server".
Args:
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
Returns:
List of well-known discovery routes
"""
routes = super().get_well_known_routes(mcp_path)
# RFC 8414: If issuer_url has a path, use path-aware discovery
if self.issuer_url:
parsed = urlparse(str(self.issuer_url))
issuer_path = parsed.path.rstrip("/")
if issuer_path and issuer_path != "/":
# Replace /.well-known/oauth-authorization-server with path-aware version
new_routes = []
for route in routes:
if route.path == "/.well-known/oauth-authorization-server":
new_path = (
f"/.well-known/oauth-authorization-server{issuer_path}"
)
new_routes.append(
Route(
new_path,
endpoint=route.endpoint,
methods=route.methods,
)
)
else:
new_routes.append(route)
return new_routes
return routes

View file

@ -265,3 +265,116 @@ class TestOAuthMounting:
"https://api.example.com/api",
"https://api.example.com/api/",
]
async def test_oauth_authorization_server_metadata_path_aware_discovery(
self, test_tokens
):
"""Test RFC 8414 path-aware discovery when issuer_url has a path.
This validates the fix for issue #2527 where authorization server metadata
should be exposed at a path-aware URL when issuer_url has a path component.
When issuer_url defaults to base_url (e.g., http://example.com/api), the
authorization server metadata should be at:
/.well-known/oauth-authorization-server/api
This is consistent with how protected resource metadata already works
(RFC 9728) and complies with RFC 8414 path-aware discovery.
"""
# Create OAuth proxy where issuer_url defaults to base_url (which has a path)
token_verifier = StaticTokenVerifier(tokens=test_tokens)
auth_provider = OAuthProxy(
upstream_authorization_endpoint="https://upstream.example.com/authorize",
upstream_token_endpoint="https://upstream.example.com/token",
upstream_client_id="test-client-id",
upstream_client_secret="test-client-secret",
token_verifier=token_verifier,
base_url="https://api.example.com/api", # Has path, no explicit issuer_url
)
mcp = FastMCP("test-server", auth=auth_provider)
mcp_app = mcp.http_app(path="/mcp")
# Get well-known routes - should include path-aware authorization server metadata
well_known_routes = auth_provider.get_well_known_routes(mcp_path="/mcp")
# Find the authorization server metadata route
auth_server_routes = [
r for r in well_known_routes if "oauth-authorization-server" in r.path
]
assert len(auth_server_routes) == 1
# The route should be path-aware (RFC 8414)
assert (
auth_server_routes[0].path == "/.well-known/oauth-authorization-server/api"
)
# Find the protected resource metadata route for comparison
protected_resource_routes = [
r for r in well_known_routes if "oauth-protected-resource" in r.path
]
assert len(protected_resource_routes) == 1
# Protected resource should also be path-aware (RFC 9728)
assert (
protected_resource_routes[0].path
== "/.well-known/oauth-protected-resource/api/mcp"
)
# Mount the app and verify the routes are accessible
parent_app = Starlette(
routes=[
*well_known_routes,
Mount("/api", app=mcp_app),
],
lifespan=mcp_app.lifespan,
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=parent_app),
base_url="https://api.example.com",
) as client:
# Path-aware authorization server metadata should be accessible
response = await client.get("/.well-known/oauth-authorization-server/api")
assert response.status_code == 200
metadata = response.json()
assert (
metadata["authorization_endpoint"]
== "https://api.example.com/api/authorize"
)
assert metadata["token_endpoint"] == "https://api.example.com/api/token"
# Path-aware protected resource metadata should also work
response = await client.get("/.well-known/oauth-protected-resource/api/mcp")
assert response.status_code == 200
async def test_oauth_authorization_server_metadata_root_issuer(self, test_tokens):
"""Test that root-level issuer_url still uses root discovery path.
When issuer_url is explicitly set to root (no path), the authorization
server metadata should remain at the root path:
/.well-known/oauth-authorization-server
This maintains backwards compatibility with the documented mounting pattern.
"""
token_verifier = StaticTokenVerifier(tokens=test_tokens)
auth_provider = OAuthProxy(
upstream_authorization_endpoint="https://upstream.example.com/authorize",
upstream_token_endpoint="https://upstream.example.com/token",
upstream_client_id="test-client-id",
upstream_client_secret="test-client-secret",
token_verifier=token_verifier,
base_url="https://api.example.com/api",
issuer_url="https://api.example.com", # Explicitly root
)
well_known_routes = auth_provider.get_well_known_routes(mcp_path="/mcp")
# Find the authorization server metadata route
auth_server_routes = [
r for r in well_known_routes if "oauth-authorization-server" in r.path
]
assert len(auth_server_routes) == 1
# Should be at root (no path suffix) when issuer_url is root
assert auth_server_routes[0].path == "/.well-known/oauth-authorization-server"