mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-14 17:49:11 +02:00
* Add issuer_url parameter to OAuth providers for mounting scenarios * Add get_well_known_routes * Update docs * Improve docs and tests * Trigger CI * Fix conditional test execution for Windows
196 lines
7.7 KiB
Python
196 lines
7.7 KiB
Python
"""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.
|
|
|
|
This test uses the CANONICAL pattern for mounting:
|
|
- base_url includes the mount prefix ("/api")
|
|
- mcp_path is just the internal MCP path ("/mcp")
|
|
- These combine: base_url + mcp_path = actual URL
|
|
|
|
The well-known routes are mounted at root level for RFC 9728 compliance.
|
|
"""
|
|
token_verifier = StaticTokenVerifier(tokens=test_tokens)
|
|
# CANONICAL PATTERN: base_url includes the mount prefix
|
|
auth_provider = RemoteAuthProvider(
|
|
token_verifier=token_verifier,
|
|
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
|
|
base_url="https://api.example.com/api", # Includes /api mount prefix
|
|
)
|
|
|
|
mcp = FastMCP("test-server", auth=auth_provider)
|
|
mcp_app = mcp.http_app(path="/mcp")
|
|
|
|
# Pass just the internal mcp_path, NOT the full mount path
|
|
# The auth provider will combine base_url + mcp_path internally
|
|
well_known_routes = auth_provider.get_well_known_routes(mcp_path="/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.
|
|
|
|
Uses CANONICAL pattern: base_url includes all mount prefixes.
|
|
"""
|
|
token_verifier = StaticTokenVerifier(tokens=test_tokens)
|
|
# CANONICAL PATTERN: base_url includes full mount path /outer/inner
|
|
auth_provider = RemoteAuthProvider(
|
|
token_verifier=token_verifier,
|
|
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
|
|
base_url="https://api.example.com/outer/inner", # Includes nested mount path
|
|
)
|
|
|
|
mcp = FastMCP("test-server", auth=auth_provider)
|
|
mcp_app = mcp.http_app(path="/mcp")
|
|
|
|
# Pass just the internal mcp_path
|
|
well_known_routes = auth_provider.get_well_known_routes(mcp_path="/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"
|