Skip to content

Commit 9cade6c

Browse files
authored
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
1 parent ee63405 commit 9cade6c

File tree

8 files changed

+412
-33
lines changed

8 files changed

+412
-33
lines changed

AGENTS.md

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,24 +20,24 @@ uv run pytest # Run full test suite
2020

2121
## Repository Structure
2222

23-
| Path | Purpose |
24-
| ------------------ | --------------------------------------------------- |
25-
| `src/fastmcp/` | Library source code (Python ≥ 3.10) |
26-
| `├─server/` | Server implementation, `FastMCP`, auth, networking |
23+
| Path | Purpose |
24+
| ------------------ | ----------------------------------------------------------------------------------- |
25+
| `src/fastmcp/` | Library source code (Python ≥ 3.10) |
26+
| `├─server/` | Server implementation, `FastMCP`, auth, networking |
2727
| `│ ├─auth/` | Authentication providers (Google, GitHub, Azure, AWS, WorkOS, Auth0, JWT, and more) |
28-
| `│ └─middleware/` | Error handling, logging, rate limiting |
29-
| `├─client/` | High-level client SDK + transports |
30-
| `│ └─auth/` | Client authentication (Bearer, OAuth) |
31-
| `├─tools/` | Tool implementations + `ToolManager` |
32-
| `├─resources/` | Resources, templates + `ResourceManager` |
33-
| `├─prompts/` | Prompt templates + `PromptManager` |
34-
| `├─cli/` | FastMCP CLI commands (`run`, `dev`, `install`) |
35-
| `├─contrib/` | Community contributions (bulk caller, mixins) |
36-
| `├─experimental/` | Experimental features (sampling handlers) |
37-
| `└─utilities/` | Shared utilities (logging, JSON schema, HTTP) |
38-
| `tests/` | Comprehensive pytest suite with markers |
39-
| `docs/` | Mintlify documentation (published to gofastmcp.com) |
40-
| `examples/` | Runnable demo servers (echo, smart_home, atproto) |
28+
| `│ └─middleware/` | Error handling, logging, rate limiting |
29+
| `├─client/` | High-level client SDK + transports |
30+
| `│ └─auth/` | Client authentication (Bearer, OAuth) |
31+
| `├─tools/` | Tool implementations + `ToolManager` |
32+
| `├─resources/` | Resources, templates + `ResourceManager` |
33+
| `├─prompts/` | Prompt templates + `PromptManager` |
34+
| `├─cli/` | FastMCP CLI commands (`run`, `dev`, `install`) |
35+
| `├─contrib/` | Community contributions (bulk caller, mixins) |
36+
| `├─experimental/` | Experimental features (sampling handlers) |
37+
| `└─utilities/` | Shared utilities (logging, JSON schema, HTTP) |
38+
| `tests/` | Comprehensive pytest suite with markers |
39+
| `docs/` | Mintlify documentation (published to gofastmcp.com) |
40+
| `examples/` | Runnable demo servers (echo, smart_home, atproto) |
4141

4242
## Core MCP Objects
4343

@@ -106,6 +106,7 @@ async with Client(transport=StreamableHttpTransport(server_url)) as client:
106106
- Improvements = enhancements (not features) unless specified
107107
- **NEVER** force-push on collaborative repos
108108
- **ALWAYS** run prek before PRs
109+
- **NEVER** create a release, comment on an issue, or open a PR unless specifically instructed to do so.
109110

110111
### Commit Messages and Agent Attribution
111112

docs/deployment/http.mdx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -328,8 +328,6 @@ OAuth specifications (RFC 8414 and RFC 9728) require discovery metadata to be ac
328328
# Result: /api/api/mcp (double prefix!)
329329
```
330330

331-
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.
332-
333331
Follow the configuration instructions below to set up mounting correctly.
334332
</Warning>
335333

@@ -373,12 +371,15 @@ base_url="http://localhost:8000/api" # Includes mount prefix
373371
mcp_path="/mcp" # Internal MCP path, NOT the mount prefix
374372
```
375373

376-
**`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:
374+
**`issuer_url`** (optional) controls the authorization server identity for OAuth discovery. Defaults to `base_url`.
377375

378376
```python
379-
issuer_url="http://localhost:8000" # Root level, no prefix
377+
# Usually not needed - just set base_url and it works
378+
issuer_url="http://localhost:8000" # Only if you want root-level discovery
380379
```
381380

381+
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`.
382+
382383
**Key Invariant:** `base_url + mcp_path = actual externally-accessible MCP URL`
383384

384385
Example:
@@ -404,14 +405,14 @@ MOUNT_PREFIX = "/api"
404405
MCP_PATH = "/mcp"
405406
```
406407

407-
Create the auth provider with both `issuer_url` and `base_url`:
408+
Create the auth provider with `base_url`:
408409

409410
```python
410411
auth = GitHubProvider(
411412
client_id="your-client-id",
412413
client_secret="your-client-secret",
413-
issuer_url=ROOT_URL, # Discovery metadata at root
414414
base_url=f"{ROOT_URL}{MOUNT_PREFIX}", # Operational endpoints under prefix
415+
# issuer_url defaults to base_url - path-aware discovery works automatically
415416
)
416417
```
417418

@@ -445,9 +446,11 @@ This configuration produces the following URL structure:
445446
- MCP endpoint: `http://localhost:8000/api/mcp`
446447
- OAuth authorization: `http://localhost:8000/api/authorize`
447448
- OAuth callback: `http://localhost:8000/api/auth/callback`
448-
- Authorization server metadata: `http://localhost:8000/.well-known/oauth-authorization-server`
449+
- Authorization server metadata: `http://localhost:8000/.well-known/oauth-authorization-server/api`
449450
- Protected resource metadata: `http://localhost:8000/.well-known/oauth-protected-resource/api/mcp`
450451

452+
Both discovery endpoints use path-aware URLs per RFC 8414 and RFC 9728, matching the `base_url` path.
453+
451454
### Complete Example
452455

453456
Here's a complete working example showing all the pieces together:
@@ -468,8 +471,8 @@ MCP_PATH = "/mcp"
468471
auth = GitHubProvider(
469472
client_id="your-client-id",
470473
client_secret="your-client-secret",
471-
issuer_url=ROOT_URL,
472474
base_url=f"{ROOT_URL}{MOUNT_PREFIX}",
475+
# issuer_url defaults to base_url - path-aware discovery works automatically
473476
)
474477

475478
# Create MCP server

docs/servers/auth/oauth-proxy.mdx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -127,21 +127,24 @@ mcp = FastMCP(name="My Server", auth=auth)
127127
<ParamField body="issuer_url" type="AnyHttpUrl | str | None">
128128
Issuer URL for OAuth authorization server metadata (defaults to `base_url`).
129129

130-
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.
130+
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`.
131131

132-
**Example with mounting:**
132+
**Default behavior (recommended for most cases):**
133133
```python
134134
auth = GitHubProvider(
135135
base_url="http://localhost:8000/api", # OAuth endpoints under /api
136-
issuer_url="http://localhost:8000" # Auth server metadata at root
136+
# issuer_url defaults to base_url - path-aware discovery works automatically
137137
)
138138
```
139139

140-
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.
141-
142-
**When to use:**
143-
- **Default (`None`)**: Use `base_url` as issuer - simple deployments at root path
144-
- **Root-level URL**: Mounting under a path prefix - avoids 404 logs
140+
**When to set explicitly:**
141+
Set `issuer_url` to root level only if you want multiple MCP servers to share a single discovery endpoint:
142+
```python
143+
auth = GitHubProvider(
144+
base_url="http://localhost:8000/api",
145+
issuer_url="http://localhost:8000" # Shared root-level discovery
146+
)
147+
```
145148

146149
See the [HTTP Deployment guide](/deployment/http#mounting-authenticated-servers) for complete mounting examples.
147150
</ParamField>

examples/auth/mounted/README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Multi-Provider OAuth Example
2+
3+
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.
4+
5+
## URL Structure
6+
7+
- **GitHub MCP**: `http://localhost:8000/api/mcp/github/mcp`
8+
- **Google MCP**: `http://localhost:8000/api/mcp/google/mcp`
9+
10+
Discovery endpoints (RFC 8414 path-aware):
11+
- **GitHub**: `http://localhost:8000/.well-known/oauth-authorization-server/api/mcp/github`
12+
- **Google**: `http://localhost:8000/.well-known/oauth-authorization-server/api/mcp/google`
13+
14+
## Setup
15+
16+
Set environment variables for both providers:
17+
18+
```bash
19+
export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID="your-github-client-id"
20+
export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET="your-github-client-secret"
21+
export FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID="your-google-client-id"
22+
export FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET="your-google-client-secret"
23+
```
24+
25+
Configure redirect URIs in each provider's developer console (note the `/api/mcp/{provider}` prefix since the servers are mounted):
26+
- GitHub: `http://localhost:8000/api/mcp/github/auth/callback/github`
27+
- Google: `http://localhost:8000/api/mcp/google/auth/callback/google`
28+
29+
## Running
30+
31+
Start the server:
32+
```bash
33+
python server.py
34+
```
35+
36+
Connect with the client:
37+
```bash
38+
python client.py
39+
```

examples/auth/mounted/client.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Mounted OAuth servers client example for FastMCP.
2+
3+
This example demonstrates connecting to multiple mounted OAuth-protected MCP servers.
4+
5+
To run:
6+
python client.py
7+
"""
8+
9+
import asyncio
10+
11+
from fastmcp.client import Client
12+
13+
GITHUB_URL = "http://127.0.0.1:8000/api/mcp/github/mcp"
14+
GOOGLE_URL = "http://127.0.0.1:8000/api/mcp/google/mcp"
15+
16+
17+
async def main():
18+
# Connect to GitHub server
19+
print("\n--- GitHub Server ---")
20+
try:
21+
async with Client(GITHUB_URL, auth="oauth") as client:
22+
assert await client.ping()
23+
print("✅ Successfully authenticated!")
24+
25+
tools = await client.list_tools()
26+
print(f"🔧 Available tools ({len(tools)}):")
27+
for tool in tools:
28+
print(f" - {tool.name}: {tool.description}")
29+
except Exception as e:
30+
print(f"❌ Authentication failed: {e}")
31+
raise
32+
33+
# Connect to Google server
34+
print("\n--- Google Server ---")
35+
try:
36+
async with Client(GOOGLE_URL, auth="oauth") as client:
37+
assert await client.ping()
38+
print("✅ Successfully authenticated!")
39+
40+
tools = await client.list_tools()
41+
print(f"🔧 Available tools ({len(tools)}):")
42+
for tool in tools:
43+
print(f" - {tool.name}: {tool.description}")
44+
except Exception as e:
45+
print(f"❌ Authentication failed: {e}")
46+
raise
47+
48+
49+
if __name__ == "__main__":
50+
asyncio.run(main())

examples/auth/mounted/server.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Mounted OAuth servers example for FastMCP.
2+
3+
This example demonstrates mounting multiple OAuth-protected MCP servers in a single
4+
application, each with its own provider. It showcases RFC 8414 path-aware discovery
5+
where each server has its own authorization server metadata endpoint.
6+
7+
URL structure:
8+
- GitHub MCP: http://localhost:8000/api/mcp/github/mcp
9+
- Google MCP: http://localhost:8000/api/mcp/google/mcp
10+
- GitHub discovery: http://localhost:8000/.well-known/oauth-authorization-server/api/mcp/github
11+
- Google discovery: http://localhost:8000/.well-known/oauth-authorization-server/api/mcp/google
12+
13+
Required environment variables:
14+
- FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID: Your GitHub OAuth app client ID
15+
- FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET: Your GitHub OAuth app client secret
16+
- FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID: Your Google OAuth client ID
17+
- FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET: Your Google OAuth client secret
18+
19+
To run:
20+
python server.py
21+
"""
22+
23+
import os
24+
25+
import uvicorn
26+
from starlette.applications import Starlette
27+
from starlette.routing import Mount
28+
29+
from fastmcp import FastMCP
30+
from fastmcp.server.auth.providers.github import GitHubProvider
31+
from fastmcp.server.auth.providers.google import GoogleProvider
32+
33+
# Configuration
34+
ROOT_URL = "http://localhost:8000"
35+
API_PREFIX = "/api/mcp"
36+
37+
# --- GitHub OAuth Server ---
38+
github_auth = GitHubProvider(
39+
client_id=os.getenv("FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID") or "",
40+
client_secret=os.getenv("FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET") or "",
41+
base_url=f"{ROOT_URL}{API_PREFIX}/github",
42+
redirect_path="/auth/callback/github",
43+
)
44+
45+
github_mcp = FastMCP("GitHub Server", auth=github_auth)
46+
47+
48+
@github_mcp.tool
49+
def github_echo(message: str) -> str:
50+
"""Echo from the GitHub-authenticated server."""
51+
return f"[GitHub] {message}"
52+
53+
54+
@github_mcp.tool
55+
def github_info() -> str:
56+
"""Get info about the GitHub server."""
57+
return "This is the GitHub OAuth protected MCP server"
58+
59+
60+
# --- Google OAuth Server ---
61+
google_auth = GoogleProvider(
62+
client_id=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID") or "",
63+
client_secret=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET") or "",
64+
base_url=f"{ROOT_URL}{API_PREFIX}/google",
65+
redirect_path="/auth/callback/google",
66+
)
67+
68+
google_mcp = FastMCP("Google Server", auth=google_auth)
69+
70+
71+
@google_mcp.tool
72+
def google_echo(message: str) -> str:
73+
"""Echo from the Google-authenticated server."""
74+
return f"[Google] {message}"
75+
76+
77+
@google_mcp.tool
78+
def google_info() -> str:
79+
"""Get info about the Google server."""
80+
return "This is the Google OAuth protected MCP server"
81+
82+
83+
# --- Create ASGI apps ---
84+
github_app = github_mcp.http_app(path="/mcp")
85+
google_app = google_mcp.http_app(path="/mcp")
86+
87+
# Get well-known routes for each provider (path-aware per RFC 8414)
88+
github_well_known = github_auth.get_well_known_routes(mcp_path="/mcp")
89+
google_well_known = google_auth.get_well_known_routes(mcp_path="/mcp")
90+
91+
# --- Combine into single application ---
92+
# Note: Each provider has its own path-aware discovery endpoint:
93+
# - /.well-known/oauth-authorization-server/api/mcp/github
94+
# - /.well-known/oauth-authorization-server/api/mcp/google
95+
app = Starlette(
96+
routes=[
97+
# Well-known routes at root level (path-aware)
98+
*github_well_known,
99+
*google_well_known,
100+
# MCP servers under /api/mcp prefix
101+
Mount(f"{API_PREFIX}/github", app=github_app),
102+
Mount(f"{API_PREFIX}/google", app=google_app),
103+
],
104+
# Use one of the app lifespans (they're functionally equivalent)
105+
lifespan=github_app.lifespan,
106+
)
107+
108+
if __name__ == "__main__":
109+
print("Starting mounted OAuth servers...")
110+
print(f" GitHub MCP: {ROOT_URL}{API_PREFIX}/github/mcp")
111+
print(f" Google MCP: {ROOT_URL}{API_PREFIX}/google/mcp")
112+
print()
113+
print("Discovery endpoints (RFC 8414 path-aware):")
114+
print(
115+
f" GitHub: {ROOT_URL}/.well-known/oauth-authorization-server{API_PREFIX}/github"
116+
)
117+
print(
118+
f" Google: {ROOT_URL}/.well-known/oauth-authorization-server{API_PREFIX}/google"
119+
)
120+
print()
121+
uvicorn.run(app, host="0.0.0.0", port=8000)

0 commit comments

Comments
 (0)