mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
Compare commits
2 commits
main
...
fix-oauth-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ccd2ea9e6 | ||
|
|
e104811d0e |
3 changed files with 148 additions and 4 deletions
|
|
@ -65,3 +65,4 @@ While these have slightly different semantics and implementations, in general ch
|
|||
4. Make the smallest set of changes that achieve the desired outcome.
|
||||
5. Always read code before modifying it blindly.
|
||||
6. Follow established patterns and maintain consistency.
|
||||
7. Use `uv run --with pandas something.py` instead of `uv pip install pandas && uv run something.py` for one-offs.
|
||||
|
|
|
|||
|
|
@ -10,11 +10,12 @@ from mcp.server.auth.middleware.bearer_auth import (
|
|||
BearerAuthBackend,
|
||||
RequireAuthMiddleware,
|
||||
)
|
||||
from mcp.server.auth.routes import create_auth_routes
|
||||
from mcp.server.auth.routes import create_auth_routes, create_protected_resource_routes
|
||||
from mcp.server.lowlevel.server import LifespanResultT
|
||||
from mcp.server.sse import SseServerTransport
|
||||
from mcp.server.streamable_http import EventStore
|
||||
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||
|
|
@ -104,6 +105,18 @@ def setup_auth_middleware_and_routes(
|
|||
)
|
||||
)
|
||||
|
||||
# Add OAuth Protected Resource Metadata endpoint (RFC 9728)
|
||||
# This is required by the MCP spec for OAuth discovery
|
||||
# Note: We use the issuer URL as the resource URL since the actual
|
||||
# resource URL depends on the transport and path configuration
|
||||
auth_routes.extend(
|
||||
create_protected_resource_routes(
|
||||
resource_url=auth.issuer_url,
|
||||
authorization_servers=[auth.issuer_url],
|
||||
scopes_supported=auth.required_scopes,
|
||||
)
|
||||
)
|
||||
|
||||
return middleware, auth_routes, required_scopes
|
||||
|
||||
|
||||
|
|
@ -185,17 +198,30 @@ def create_sse_app(
|
|||
server_routes.extend(auth_routes)
|
||||
server_middleware.extend(auth_middleware)
|
||||
# Auth is enabled, wrap endpoints with RequireAuthMiddleware
|
||||
# Build the resource metadata URL
|
||||
resource_metadata_url = AnyHttpUrl(
|
||||
f"{str(auth.issuer_url).rstrip('/')}/.well-known/oauth-protected-resource"
|
||||
)
|
||||
|
||||
server_routes.append(
|
||||
Route(
|
||||
sse_path,
|
||||
endpoint=RequireAuthMiddleware(handle_sse, required_scopes),
|
||||
endpoint=RequireAuthMiddleware(
|
||||
handle_sse,
|
||||
required_scopes,
|
||||
resource_metadata_url=resource_metadata_url,
|
||||
),
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
server_routes.append(
|
||||
Mount(
|
||||
message_path,
|
||||
app=RequireAuthMiddleware(sse.handle_post_message, required_scopes),
|
||||
app=RequireAuthMiddleware(
|
||||
sse.handle_post_message,
|
||||
required_scopes,
|
||||
resource_metadata_url=resource_metadata_url,
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
|
|
@ -315,10 +341,19 @@ def create_streamable_http_app(
|
|||
server_middleware.extend(auth_middleware)
|
||||
|
||||
# Auth is enabled, wrap endpoint with RequireAuthMiddleware
|
||||
# Build the resource metadata URL
|
||||
resource_metadata_url = AnyHttpUrl(
|
||||
f"{str(auth.issuer_url).rstrip('/')}/.well-known/oauth-protected-resource"
|
||||
)
|
||||
|
||||
server_routes.append(
|
||||
Mount(
|
||||
streamable_http_path,
|
||||
app=RequireAuthMiddleware(handle_streamable_http, required_scopes),
|
||||
app=RequireAuthMiddleware(
|
||||
handle_streamable_http,
|
||||
required_scopes,
|
||||
resource_metadata_url=resource_metadata_url,
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
|
|
|
|||
108
tests/server/http/test_oauth_protected_resource.py
Normal file
108
tests/server/http/test_oauth_protected_resource.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"""Test OAuth protected resource metadata endpoint."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.auth import ClientRegistrationOptions
|
||||
from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oauth_server():
|
||||
"""Create a FastMCP server with OAuth enabled."""
|
||||
server = FastMCP(
|
||||
"TestServer",
|
||||
auth=InMemoryOAuthProvider(
|
||||
issuer_url="http://localhost:8000",
|
||||
client_registration_options=ClientRegistrationOptions(enabled=True),
|
||||
),
|
||||
)
|
||||
|
||||
@server.tool
|
||||
def test_tool() -> str:
|
||||
return "test"
|
||||
|
||||
return server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oauth_app(oauth_server):
|
||||
"""Create HTTP app with OAuth enabled."""
|
||||
return oauth_server.http_app()
|
||||
|
||||
|
||||
async def test_oauth_protected_resource_endpoint(oauth_app):
|
||||
"""Test that the OAuth protected resource metadata endpoint exists and returns correct data."""
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=oauth_app), base_url="http://localhost:8000"
|
||||
) as client:
|
||||
# Test GET request
|
||||
response = await client.get("/.well-known/oauth-protected-resource")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert "resource" in data
|
||||
assert "authorization_servers" in data
|
||||
assert "bearer_methods_supported" in data
|
||||
|
||||
# Check that authorization servers contains our issuer URL
|
||||
# The issuer URL might have a trailing slash
|
||||
assert len(data["authorization_servers"]) == 1
|
||||
assert data["authorization_servers"][0].rstrip("/") == "http://localhost:8000"
|
||||
assert data["bearer_methods_supported"] == ["header"]
|
||||
|
||||
|
||||
async def test_oauth_protected_resource_options_request(oauth_app):
|
||||
"""Test that the OAuth protected resource endpoint responds to OPTIONS requests."""
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=oauth_app), base_url="http://localhost:8000"
|
||||
) as client:
|
||||
# Test simple OPTIONS request - the endpoint should at least respond
|
||||
response = await client.options("/.well-known/oauth-protected-resource")
|
||||
# The endpoint exists and handles OPTIONS (even if it returns various status codes)
|
||||
assert response.status_code < 500 # Not a server error
|
||||
|
||||
|
||||
async def test_oauth_authorization_server_endpoint_still_exists(oauth_app):
|
||||
"""Test that the existing OAuth authorization server endpoint still works."""
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=oauth_app), base_url="http://localhost:8000"
|
||||
) as client:
|
||||
response = await client.get("/.well-known/oauth-authorization-server")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert "issuer" in data
|
||||
assert "authorization_endpoint" in data
|
||||
assert "token_endpoint" in data
|
||||
assert "registration_endpoint" in data
|
||||
|
||||
|
||||
async def test_www_authenticate_header_includes_resource_metadata():
|
||||
"""Test that 401 responses include the resource metadata URL in WWW-Authenticate header."""
|
||||
server = FastMCP(
|
||||
"TestServer",
|
||||
auth=InMemoryOAuthProvider(
|
||||
issuer_url="http://localhost:8000",
|
||||
),
|
||||
)
|
||||
|
||||
app = server.http_app()
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://localhost:8000"
|
||||
) as client:
|
||||
# Make a request without authentication
|
||||
response = await client.get("/mcp/")
|
||||
assert response.status_code == 401
|
||||
|
||||
# Check WWW-Authenticate header
|
||||
www_auth = response.headers.get("www-authenticate")
|
||||
assert www_auth is not None
|
||||
assert "Bearer" in www_auth
|
||||
assert (
|
||||
'resource_metadata="http://localhost:8000/.well-known/oauth-protected-resource"'
|
||||
in www_auth
|
||||
)
|
||||
assert 'error="invalid_token"' in www_auth
|
||||
Loading…
Add table
Add a link
Reference in a new issue