Update version badge for azure auth docs

This commit is contained in:
Jeremiah Lowin 2025-10-16 21:19:54 -04:00
commit 8b0537e295
10 changed files with 335 additions and 39 deletions

View file

@ -152,6 +152,68 @@ app = Starlette(
In this setup, the MCP server is accessible at the `/outer/inner/mcp/` path.
## Mounting OAuth-Protected Servers
When mounting an OAuth-protected FastMCP server under a path prefix, you need to ensure OAuth discovery endpoints are accessible at the root level for RFC 9728 compliance.
### Understanding `base_url` with Mounting
The `base_url` parameter represents **where the OAuth endpoints will be publicly accessible**, including any mount path:
```python
from fastmcp import FastMCP
from fastmcp.server.auth.providers.github import GitHubProvider
from starlette.applications import Starlette
from starlette.routing import Mount
# Create OAuth provider
auth = GitHubProvider(
client_id="your-client-id",
client_secret="your-client-secret",
base_url="http://localhost:8000/api", # Where OAuth endpoints will be accessible
issuer_url="http://localhost:8000", # Where auth server metadata is located (root level)
)
mcp = FastMCP("Protected Server", auth=auth)
# Create MCP app with internal path
mcp_app = mcp.http_app(path="/mcp")
# Get well-known routes (mounted at root level)
well_known_routes = auth.get_routes(mcp_path="/mcp")
# Mount everything
app = Starlette(
routes=[
*well_known_routes, # Well-known discovery routes at root level
Mount("/api", app=mcp_app), # MCP app under /api prefix
],
lifespan=mcp_app.lifespan,
)
```
**Key Parameters:**
- `base_url`: Where OAuth endpoints (authorize, token, callback) will be accessible. **Includes the mount path.**
- `issuer_url`: Where auth server metadata is located. **Should be root level** when well-known routes are mounted at root.
- `mcp_path`: Internal MCP path that combines with `base_url` to form the resource URL.
**How it works:**
1. `base_url="http://localhost:8000/api"` - OAuth endpoints accessible under `/api`
2. `issuer_url="http://localhost:8000"` - Auth server metadata at root level
3. Internal MCP routes created at `/mcp`
4. When mounted under `/api`, they become accessible at `/api/mcp`
5. Well-known routes mounted at root for RFC compliance
**Result:**
- MCP endpoint: `http://localhost:8000/api/mcp`
- OAuth callback: `http://localhost:8000/api/auth/callback`
- Auth server metadata: `http://localhost:8000/.well-known/oauth-authorization-server`
- Protected resource metadata: `http://localhost:8000/.well-known/oauth-protected-resource/api/mcp`
For more details on OAuth authentication, see the [Authentication guide](/servers/auth).
## Custom Middleware
<VersionBadge version="2.3.2" />

View file

@ -8,7 +8,7 @@ tag: NEW
import { VersionBadge } from "/snippets/version-badge.mdx"
<VersionBadge version="2.12.0" />
<VersionBadge version="2.13.0" />
This guide shows you how to secure your FastMCP server using **Azure OAuth** (Microsoft Entra ID). Since Azure doesn't support Dynamic Client Registration, this integration uses the [**OAuth Proxy**](/servers/auth/oauth-proxy) pattern to bridge Azure's traditional OAuth with MCP's authentication requirements. FastMCP validates Azure JWTs against your application's client_id.

View file

@ -10,7 +10,7 @@ import asyncio
from fastmcp.client import Client
SERVER_URL = "http://127.0.0.1:8000/mcp"
SERVER_URL = "http://127.0.0.1:8000/api/mcp"
async def main():

View file

@ -1,6 +1,7 @@
"""GitHub OAuth server example for FastMCP.
"""GitHub OAuth server example for FastMCP with mounting.
This example demonstrates how to protect a FastMCP server with GitHub OAuth.
This example demonstrates how to protect a FastMCP server with GitHub OAuth
and mount it under a path prefix in a parent ASGI application.
Required environment variables:
- FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID: Your GitHub OAuth app client ID
@ -12,13 +13,18 @@ To run:
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
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="http://localhost:8000",
base_url="http://localhost:8000/api", # Where OAuth endpoints will be accessible
issuer_url="http://localhost:8000", # Where auth server metadata is located (root level)
# redirect_path="/auth/callback", # Default path - change if using a different callback URL
)
@ -32,4 +38,25 @@ def echo(message: str) -> str:
if __name__ == "__main__":
mcp.run(transport="http", port=8000)
# Create the MCP app with internal path only
mcp_app = mcp.http_app(path="/mcp")
# Get well-known routes (mounted at root level for RFC compliance)
# Pass the internal MCP path - it combines with base_url internally
well_known_routes = auth.get_routes(mcp_path="/mcp")
# Create parent app and mount everything
app = Starlette(
routes=[
*well_known_routes, # Well-known discovery routes at root level
Mount("/api", app=mcp_app), # MCP app mounted under /api prefix
],
lifespan=mcp_app.lifespan,
)
# URLs after mounting:
# - MCP endpoint: http://localhost:8000/api/mcp
# - OAuth callback: http://localhost:8000/api/auth/callback
# - Auth server metadata: http://localhost:8000/.well-known/oauth-authorization-server
# - Protected resource metadata: http://localhost:8000/.well-known/oauth-protected-resource/api/mcp
uvicorn.run(app, host="127.0.0.1", port=8000)

View file

@ -7,7 +7,7 @@ dependencies = [
"python-dotenv>=1.1.0",
"exceptiongroup>=1.2.2",
"httpx>=0.28.1",
"mcp>=1.12.4,<2.0.0",
"mcp>=1.17.0,<2.0.0",
"openapi-pydantic>=0.5.1",
"rich>=13.9.4",
"cyclopts>=3.0.0",

View file

@ -6,6 +6,7 @@ from contextvars import ContextVar
from typing import TYPE_CHECKING
from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware
from mcp.server.auth.routes import build_resource_metadata_url
from mcp.server.lowlevel.server import LifespanResultT
from mcp.server.sse import SseServerTransport
from mcp.server.streamable_http import EventStore
@ -173,13 +174,17 @@ def create_sse_app(
server_middleware.extend(auth_middleware)
# Create protected SSE endpoint route with GET method only
# Use RFC 9728 path-scoped well-known URL for WWW-Authenticate header
resource_url = auth._get_resource_url(sse_path)
metadata_url = build_resource_metadata_url(resource_url) if resource_url else None
server_routes.append(
Route(
sse_path,
endpoint=RequireAuthMiddleware(
handle_sse,
auth.required_scopes,
auth._get_resource_url("/.well-known/oauth-protected-resource"),
metadata_url,
),
methods=["GET"],
)
@ -192,7 +197,7 @@ def create_sse_app(
app=RequireAuthMiddleware(
sse.handle_post_message,
auth.required_scopes,
auth._get_resource_url("/.well-known/oauth-protected-resource"),
metadata_url,
),
)
)
@ -295,13 +300,17 @@ def create_streamable_http_app(
server_middleware.extend(auth_middleware)
# Create protected HTTP endpoint route
# Use RFC 9728 path-scoped well-known URL for WWW-Authenticate header
resource_url = auth._get_resource_url(streamable_http_path)
metadata_url = build_resource_metadata_url(resource_url) if resource_url else None
server_routes.append(
Route(
streamable_http_path,
endpoint=RequireAuthMiddleware(
streamable_http_app,
auth.required_scopes,
auth._get_resource_url("/.well-known/oauth-protected-resource"),
metadata_url,
),
)
)

View file

@ -57,10 +57,11 @@ class TestAuthProviderBase:
assert match is not None
metadata_url = match.group(1)
# Should point to base URL, not include /api/v1/mcp
# RFC 9728: Should be path-scoped well-known URL
# Resource is at /api/v1/mcp, so well-known is at /.well-known/oauth-protected-resource/api/v1/mcp
assert (
metadata_url
== "https://my-server.com/.well-known/oauth-protected-resource"
== "https://my-server.com/.well-known/oauth-protected-resource/api/v1/mcp"
)
async def test_automatic_resource_url_capture(self, basic_remote_provider):
@ -78,7 +79,7 @@ class TestAuthProviderBase:
base_url="https://my-server.com",
) as client:
# Get the .well-known metadata
response = await client.get("/.well-known/oauth-protected-resource")
response = await client.get("/.well-known/oauth-protected-resource/mcp")
assert response.status_code == 200
data = response.json()
@ -94,7 +95,8 @@ class TestAuthProviderBase:
transport=httpx.ASGITransport(app=mcp_http_app),
base_url="https://my-server.com",
) as client:
response = await client.get("/.well-known/oauth-protected-resource")
# RFC 9728: path-scoped well-known URL
response = await client.get("/.well-known/oauth-protected-resource/api/v2/services/mcp")
assert response.status_code == 200
data = response.json()

View file

@ -0,0 +1,194 @@
"""Tests for OAuth .well-known routes when FastMCP apps are mounted in parent ASGI apps.
This test file validates the fix for issue #2077 where .well-known/oauth-protected-resource
returns 404 at root level when a FastMCP app is mounted under a path prefix.
The fix uses MCP SDK 1.17+ which implements RFC 9728 path-scoped well-known URLs.
"""
import httpx
import pytest
from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from starlette.routing import Mount
from fastmcp import FastMCP
from fastmcp.server.auth import RemoteAuthProvider
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier
@pytest.fixture
def test_tokens():
"""Standard test tokens fixture."""
return {
"test_token": {
"client_id": "test-client",
"scopes": ["read", "write"],
}
}
class TestOAuthMounting:
"""Test OAuth .well-known routes with mounted FastMCP apps."""
async def test_well_known_with_direct_deployment(self, test_tokens):
"""Test that .well-known routes work when app is deployed directly (not mounted).
This is the baseline - it should work as expected.
Per RFC 9728, if the resource is at /mcp, the well-known endpoint is at
/.well-known/oauth-protected-resource/mcp (path-scoped).
"""
token_verifier = StaticTokenVerifier(tokens=test_tokens)
auth_provider = RemoteAuthProvider(
token_verifier=token_verifier,
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
base_url="https://api.example.com",
)
mcp = FastMCP("test-server", auth=auth_provider)
mcp_app = mcp.http_app()
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=mcp_app),
base_url="https://api.example.com",
) as client:
# RFC 9728: path-scoped well-known URL
# Resource is at /mcp, so well-known should be at /.well-known/oauth-protected-resource/mcp
response = await client.get("/.well-known/oauth-protected-resource/mcp")
assert response.status_code == 200
data = response.json()
assert data["resource"] == "https://api.example.com/mcp"
assert data["authorization_servers"] == ["https://auth.example.com/"]
async def test_well_known_with_mounted_app(self, test_tokens):
"""Test that .well-known routes work when explicitly mounted at root.
When mounting a FastMCP app under a prefix, users should:
1. Get the well-known routes from auth provider with the FULL mount path
2. Mount those routes directly on the parent app (at root level)
3. Mount the MCP app under the desired prefix
This ensures RFC 9728 compliance - the well-known endpoint is at root.
"""
token_verifier = StaticTokenVerifier(tokens=test_tokens)
auth_provider = RemoteAuthProvider(
token_verifier=token_verifier,
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
base_url="https://api.example.com",
)
mcp = FastMCP("test-server", auth=auth_provider)
mcp_app = mcp.http_app(path="/mcp")
# Get well-known routes for the FULL mount path (/api/mcp)
# and mount them at root level on the parent app
well_known_routes = auth_provider.get_routes(mcp_path="/api/mcp")
parent_app = Starlette(
routes=[
*well_known_routes, # Well-known routes at root level
Mount("/api", app=mcp_app), # MCP app under /api
],
lifespan=mcp_app.lifespan,
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=parent_app),
base_url="https://api.example.com",
) as client:
# The CORRECT RFC 9728 path-scoped well-known URL at root
# Resource is at /api/mcp, so well-known is at /.well-known/oauth-protected-resource/api/mcp
response = await client.get("/.well-known/oauth-protected-resource/api/mcp")
assert response.status_code == 200
data = response.json()
assert data["resource"] == "https://api.example.com/api/mcp"
assert data["authorization_servers"] == ["https://auth.example.com/"]
# There will also be an extra route at /api/.well-known/oauth-protected-resource/mcp
# (from the mounted MCP app), but we don't care about that as long as the correct one exists
async def test_mcp_endpoint_with_mounted_app(self, test_tokens):
"""Test that MCP endpoint works correctly when mounted.
This confirms the MCP functionality itself works with mounting.
"""
token_verifier = StaticTokenVerifier(tokens=test_tokens)
auth_provider = RemoteAuthProvider(
token_verifier=token_verifier,
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
base_url="https://api.example.com",
)
mcp = FastMCP("test-server", auth=auth_provider)
@mcp.tool
def test_tool(message: str) -> str:
return f"Echo: {message}"
mcp_app = mcp.http_app(path="/mcp")
# Mount the MCP app under /api prefix
parent_app = Starlette(
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:
# The MCP endpoint should work at /api/mcp (mounted correctly)
# This is a basic connectivity test
response = await client.get("/api/mcp")
# We expect either 200 (if no auth required for GET) or 401 (if auth required)
# The key is that it's NOT 404
assert response.status_code in [200, 401, 405]
async def test_nested_mounting(self, test_tokens):
"""Test .well-known routes with deeply nested mounts.
Same pattern as single mount - explicitly mount well-known routes at root.
"""
token_verifier = StaticTokenVerifier(tokens=test_tokens)
auth_provider = RemoteAuthProvider(
token_verifier=token_verifier,
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
base_url="https://api.example.com",
)
mcp = FastMCP("test-server", auth=auth_provider)
mcp_app = mcp.http_app(path="/mcp")
# Get well-known routes for the FULL nested mount path
well_known_routes = auth_provider.get_routes(mcp_path="/outer/inner/mcp")
# Create nested mounts
inner_app = Starlette(
routes=[Mount("/inner", app=mcp_app)],
)
outer_app = Starlette(
routes=[
*well_known_routes, # Well-known routes at root level
Mount("/outer", app=inner_app),
],
lifespan=mcp_app.lifespan,
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=outer_app),
base_url="https://api.example.com",
) as client:
# RFC 9728: path-scoped well-known URL for nested mounting
# Resource is at /outer/inner/mcp, so well-known is at /.well-known/oauth-protected-resource/outer/inner/mcp
response = await client.get(
"/.well-known/oauth-protected-resource/outer/inner/mcp"
)
assert response.status_code == 200
data = response.json()
assert data["resource"] == "https://api.example.com/outer/inner/mcp"

View file

@ -78,6 +78,7 @@ class TestRemoteAuthProvider:
assert len(routes) == 1
# Check that the route is the OAuth protected resource metadata endpoint
# When called without mcp_path, it creates route at /.well-known/oauth-protected-resource
route = routes[0]
assert route.path == "/.well-known/oauth-protected-resource"
assert route.methods is not None
@ -99,10 +100,10 @@ class TestRemoteAuthProvider:
)
metadata_url = provider._get_resource_url(
"/.well-known/oauth-protected-resource"
"/.well-known/oauth-protected-resource/mcp"
)
assert metadata_url == AnyHttpUrl(
"https://api.example.com/.well-known/oauth-protected-resource"
"https://api.example.com/.well-known/oauth-protected-resource/mcp"
)
def test_get_resource_url_with_nested_base_url(self):
@ -121,10 +122,10 @@ class TestRemoteAuthProvider:
)
metadata_url = provider._get_resource_url(
"/.well-known/oauth-protected-resource"
"/.well-known/oauth-protected-resource/mcp"
)
assert metadata_url == AnyHttpUrl(
"https://api.example.com/v1/.well-known/oauth-protected-resource"
"https://api.example.com/v1/.well-known/oauth-protected-resource/mcp"
)
def test_get_resource_url_handles_trailing_slash(self):
@ -143,10 +144,10 @@ class TestRemoteAuthProvider:
)
metadata_url = provider._get_resource_url(
"/.well-known/oauth-protected-resource"
"/.well-known/oauth-protected-resource/mcp"
)
assert metadata_url == AnyHttpUrl(
"https://api.example.com/.well-known/oauth-protected-resource"
"https://api.example.com/.well-known/oauth-protected-resource/mcp"
)
@ -195,7 +196,7 @@ class TestRemoteAuthProviderIntegration:
transport=httpx.ASGITransport(app=mcp_http_app),
base_url="https://api.example.com",
) as client:
response = await client.get("/.well-known/oauth-protected-resource")
response = await client.get("/.well-known/oauth-protected-resource/mcp")
assert response.status_code == 200
async def test_protected_resource_metadata_endpoint_resource_field(self):
@ -209,7 +210,7 @@ class TestRemoteAuthProviderIntegration:
transport=httpx.ASGITransport(app=mcp_http_app),
base_url="https://api.example.com",
) as client:
response = await client.get("/.well-known/oauth-protected-resource")
response = await client.get("/.well-known/oauth-protected-resource/mcp")
data = response.json()
# This is the key test - ensure resource field contains the full MCP URL
@ -228,20 +229,20 @@ class TestRemoteAuthProviderIntegration:
transport=httpx.ASGITransport(app=mcp_http_app),
base_url="https://api.example.com",
) as client:
response = await client.get("/.well-known/oauth-protected-resource")
response = await client.get("/.well-known/oauth-protected-resource/mcp")
data = response.json()
assert data["authorization_servers"] == ["https://auth.example.com/"]
@pytest.mark.parametrize(
"base_url,expected_resource",
"base_url,expected_resource,well_known_path",
[
("https://api.example.com", "https://api.example.com/mcp"),
("https://api.example.com/", "https://api.example.com/mcp"),
("https://api.example.com/v1/", "https://api.example.com/v1/mcp"),
("https://api.example.com", "https://api.example.com/mcp", "/.well-known/oauth-protected-resource/mcp"),
("https://api.example.com/", "https://api.example.com/mcp", "/.well-known/oauth-protected-resource/mcp"),
("https://api.example.com/v1/", "https://api.example.com/v1/mcp", "/.well-known/oauth-protected-resource/v1/mcp"),
],
)
async def test_base_url_configurations(self, base_url: str, expected_resource: str):
async def test_base_url_configurations(self, base_url: str, expected_resource: str, well_known_path: str):
"""Test different base_url configurations."""
auth_provider = self._create_test_auth_provider(base_url=base_url)
mcp = FastMCP("test-server", auth=auth_provider)
@ -251,7 +252,8 @@ class TestRemoteAuthProviderIntegration:
transport=httpx.ASGITransport(app=mcp_http_app),
base_url="https://test.example.com",
) as client:
response = await client.get("/.well-known/oauth-protected-resource")
# RFC 9728: path-scoped well-known URL based on resource location
response = await client.get(well_known_path)
assert response.status_code == 200
data = response.json()
@ -275,7 +277,7 @@ class TestRemoteAuthProviderIntegration:
transport=httpx.ASGITransport(app=mcp_http_app),
base_url="https://api.example.com",
) as client:
response = await client.get("/.well-known/oauth-protected-resource")
response = await client.get("/.well-known/oauth-protected-resource/mcp")
data = response.json()
assert data["resource"] == "https://api.example.com/mcp"
@ -298,7 +300,7 @@ class TestRemoteAuthProviderIntegration:
transport=httpx.ASGITransport(app=mcp_http_app),
base_url="https://api.example.com",
) as client:
response = await client.get("/.well-known/oauth-protected-resource")
response = await client.get("/.well-known/oauth-protected-resource/mcp")
data = response.json()
assert set(data["authorization_servers"]) == {
@ -382,7 +384,7 @@ class TestRemoteAuthProviderIntegration:
transport=httpx.ASGITransport(app=mcp_http_app),
base_url="https://my-server.com",
) as client:
response = await client.get("/.well-known/oauth-protected-resource")
response = await client.get("/.well-known/oauth-protected-resource/mcp")
assert response.status_code == 200
data = response.json()
@ -418,7 +420,7 @@ class TestRemoteAuthProviderIntegration:
transport=httpx.ASGITransport(app=mcp_http_app),
base_url="https://my-server.com",
) as client:
response = await client.get("/.well-known/oauth-protected-resource")
response = await client.get("/.well-known/oauth-protected-resource/mcp")
assert response.status_code == 200
data = response.json()
@ -455,7 +457,7 @@ class TestRemoteAuthProviderIntegration:
transport=httpx.ASGITransport(app=mcp_http_app),
base_url="https://my-server.com",
) as client:
response = await client.get("/.well-known/oauth-protected-resource")
response = await client.get("/.well-known/oauth-protected-resource/mcp")
assert response.status_code == 200
data = response.json()

10
uv.lock generated
View file

@ -1,5 +1,5 @@
version = 1
revision = 2
revision = 3
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.11'",
@ -599,7 +599,7 @@ requires-dist = [
{ name = "cyclopts", specifier = ">=3.0.0" },
{ name = "exceptiongroup", specifier = ">=1.2.2" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "mcp", specifier = ">=1.12.4,<2.0.0" },
{ name = "mcp", specifier = ">=1.17.0,<2.0.0" },
{ name = "openai", marker = "extra == 'openai'", specifier = ">=1.102.0" },
{ name = "openapi-core", specifier = ">=0.19.5" },
{ name = "openapi-pydantic", specifier = ">=0.5.1" },
@ -1041,7 +1041,7 @@ wheels = [
[[package]]
name = "mcp"
version = "1.12.4"
version = "1.18.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@ -1056,9 +1056,9 @@ dependencies = [
{ name = "starlette" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/31/88/f6cb7e7c260cd4b4ce375f2b1614b33ce401f63af0f49f7141a2e9bf0a45/mcp-1.12.4.tar.gz", hash = "sha256:0765585e9a3a5916a3c3ab8659330e493adc7bd8b2ca6120c2d7a0c43e034ca5", size = 431148, upload-time = "2025-08-07T20:31:18.082Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1a/e0/fe34ce16ea2bacce489ab859abd1b47ae28b438c3ef60b9c5eee6c02592f/mcp-1.18.0.tar.gz", hash = "sha256:aa278c44b1efc0a297f53b68df865b988e52dd08182d702019edcf33a8e109f6", size = 482926, upload-time = "2025-10-16T19:19:55.125Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/68/316cbc54b7163fa22571dcf42c9cc46562aae0a021b974e0a8141e897200/mcp-1.12.4-py3-none-any.whl", hash = "sha256:7aa884648969fab8e78b89399d59a683202972e12e6bc9a1c88ce7eda7743789", size = 160145, upload-time = "2025-08-07T20:31:15.69Z" },
{ url = "https://files.pythonhosted.org/packages/1b/44/f5970e3e899803823826283a70b6003afd46f28e082544407e24575eccd3/mcp-1.18.0-py3-none-any.whl", hash = "sha256:42f10c270de18e7892fdf9da259029120b1ea23964ff688248c69db9d72b1d0a", size = 168762, upload-time = "2025-10-16T19:19:53.2Z" },
]
[[package]]