267 lines
9.3 KiB
Python
267 lines
9.3 KiB
Python
from typing_extensions import Annotated, Doc
|
|
from fastapi import FastAPI, HTTPException, Request, status
|
|
from fastapi.responses import RedirectResponse
|
|
import httpx
|
|
from typing import Optional
|
|
import logging
|
|
from urllib.parse import urlencode
|
|
|
|
from fastapi_mcp.types import (
|
|
ClientRegistrationRequest,
|
|
ClientRegistrationResponse,
|
|
AuthConfig,
|
|
OAuthMetadata,
|
|
OAuthMetadataDict,
|
|
StrHttpUrl,
|
|
)
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def setup_oauth_custom_metadata(
|
|
app: Annotated[FastAPI, Doc("The FastAPI app instance")],
|
|
auth_config: Annotated[AuthConfig, Doc("The AuthConfig used")],
|
|
metadata: Annotated[OAuthMetadataDict, Doc("The custom metadata specified in AuthConfig")],
|
|
include_in_schema: Annotated[bool, Doc("Whether to include the metadata endpoint in your OpenAPI docs")] = False,
|
|
):
|
|
"""
|
|
Just serve the custom metadata provided to AuthConfig under the path specified in `metadata_path`.
|
|
"""
|
|
|
|
auth_config = AuthConfig.model_validate(auth_config)
|
|
metadata = OAuthMetadata.model_validate(metadata)
|
|
|
|
@app.get(
|
|
auth_config.metadata_path,
|
|
response_model=OAuthMetadata,
|
|
response_model_exclude_unset=True,
|
|
response_model_exclude_none=True,
|
|
include_in_schema=include_in_schema,
|
|
operation_id="oauth_custom_metadata",
|
|
)
|
|
async def oauth_metadata_proxy():
|
|
return metadata
|
|
|
|
|
|
def setup_oauth_metadata_proxy(
|
|
app: Annotated[FastAPI, Doc("The FastAPI app instance")],
|
|
metadata_url: Annotated[
|
|
str,
|
|
Doc(
|
|
"""
|
|
The URL of the OAuth provider's metadata endpoint that you want to proxy.
|
|
"""
|
|
),
|
|
],
|
|
path: Annotated[
|
|
str,
|
|
Doc(
|
|
"""
|
|
The path to mount the OAuth metadata endpoint at.
|
|
|
|
Clients will usually expect this to be /.well-known/oauth-authorization-server
|
|
"""
|
|
),
|
|
] = "/.well-known/oauth-authorization-server",
|
|
authorize_path: Annotated[
|
|
str,
|
|
Doc(
|
|
"""
|
|
The path to mount the authorize endpoint at.
|
|
|
|
Clients will usually expect this to be /oauth/authorize
|
|
"""
|
|
),
|
|
] = "/oauth/authorize",
|
|
register_path: Annotated[
|
|
Optional[str],
|
|
Doc(
|
|
"""
|
|
The path to mount the register endpoint at.
|
|
|
|
Clients will usually expect this to be /oauth/register
|
|
"""
|
|
),
|
|
] = None,
|
|
include_in_schema: Annotated[bool, Doc("Whether to include the metadata endpoint in your OpenAPI docs")] = False,
|
|
):
|
|
"""
|
|
Proxy for your OAuth provider's Metadata endpoint, just adding our (fake) registration endpoint.
|
|
"""
|
|
|
|
@app.get(
|
|
path,
|
|
response_model=OAuthMetadata,
|
|
response_model_exclude_unset=True,
|
|
response_model_exclude_none=True,
|
|
include_in_schema=include_in_schema,
|
|
operation_id="oauth_metadata_proxy",
|
|
)
|
|
async def oauth_metadata_proxy(request: Request):
|
|
base_url = str(request.base_url).rstrip("/")
|
|
|
|
# Fetch your OAuth provider's OpenID Connect metadata
|
|
async with httpx.AsyncClient() as client:
|
|
response = await client.get(metadata_url)
|
|
if response.status_code != 200:
|
|
logger.error(
|
|
f"Failed to fetch OAuth metadata from {metadata_url}: {response.status_code}. Response: {response.text}"
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail="Failed to fetch OAuth metadata",
|
|
)
|
|
|
|
oauth_metadata = response.json()
|
|
|
|
# Override the registration endpoint if provided
|
|
if register_path:
|
|
oauth_metadata["registration_endpoint"] = f"{base_url}{register_path}"
|
|
|
|
# Replace your OAuth provider's authorize endpoint with our proxy
|
|
oauth_metadata["authorization_endpoint"] = f"{base_url}{authorize_path}"
|
|
|
|
return OAuthMetadata.model_validate(oauth_metadata)
|
|
|
|
|
|
def setup_oauth_authorize_proxy(
|
|
app: Annotated[FastAPI, Doc("The FastAPI app instance")],
|
|
client_id: Annotated[
|
|
str,
|
|
Doc(
|
|
"""
|
|
In case the client doesn't specify a client ID, this will be used as the default client ID on the
|
|
request to your OAuth provider.
|
|
"""
|
|
),
|
|
],
|
|
authorize_url: Annotated[
|
|
Optional[StrHttpUrl],
|
|
Doc(
|
|
"""
|
|
The URL of your OAuth provider's authorization endpoint.
|
|
|
|
Usually this is something like `https://app.example.com/oauth/authorize`.
|
|
"""
|
|
),
|
|
],
|
|
audience: Annotated[
|
|
Optional[str],
|
|
Doc(
|
|
"""
|
|
Currently (2025-04-21), some Auth-supporting MCP clients (like `npx mcp-remote`) might not specify the
|
|
audience when sending a request to your server.
|
|
|
|
This may cause unexpected behavior from your OAuth provider, so this is a workaround.
|
|
|
|
In case the client doesn't specify an audience, this will be used as the default audience on the
|
|
request to your OAuth provider.
|
|
"""
|
|
),
|
|
] = None,
|
|
default_scope: Annotated[
|
|
str,
|
|
Doc(
|
|
"""
|
|
Currently (2025-04-21), some Auth-supporting MCP clients (like `npx mcp-remote`) might not specify the
|
|
scope when sending a request to your server.
|
|
|
|
This may cause unexpected behavior from your OAuth provider, so this is a workaround.
|
|
|
|
Here is where you can optionally specify a default scope that will be sent to your OAuth provider in case
|
|
the client doesn't specify it.
|
|
"""
|
|
),
|
|
] = "openid profile email",
|
|
path: Annotated[str, Doc("The path to mount the authorize endpoint at")] = "/oauth/authorize",
|
|
include_in_schema: Annotated[bool, Doc("Whether to include the authorize endpoint in your OpenAPI docs")] = False,
|
|
):
|
|
"""
|
|
Proxy for your OAuth provider's authorize endpoint that logs the requested scopes and adds
|
|
default scopes and the audience parameter if not provided.
|
|
"""
|
|
|
|
@app.get(
|
|
path,
|
|
include_in_schema=include_in_schema,
|
|
)
|
|
async def oauth_authorize_proxy(
|
|
response_type: str = "code",
|
|
client_id: Optional[str] = client_id,
|
|
redirect_uri: Optional[str] = None,
|
|
scope: str = "",
|
|
state: Optional[str] = None,
|
|
code_challenge: Optional[str] = None,
|
|
code_challenge_method: Optional[str] = None,
|
|
audience: Optional[str] = audience,
|
|
):
|
|
if not scope:
|
|
logger.warning("Client didn't provide any scopes! Using default scopes.")
|
|
scope = default_scope
|
|
logger.debug(f"Default scope: {scope}")
|
|
|
|
scopes = scope.split()
|
|
logger.debug(f"Scopes passed: {scopes}")
|
|
for required_scope in default_scope.split():
|
|
if required_scope not in scopes:
|
|
scopes.append(required_scope)
|
|
|
|
params = {
|
|
"response_type": response_type,
|
|
"client_id": client_id,
|
|
"redirect_uri": redirect_uri,
|
|
"scope": " ".join(scopes),
|
|
"audience": audience,
|
|
}
|
|
|
|
if state:
|
|
params["state"] = state
|
|
if code_challenge:
|
|
params["code_challenge"] = code_challenge
|
|
if code_challenge_method:
|
|
params["code_challenge_method"] = code_challenge_method
|
|
|
|
auth_url = f"{authorize_url}?{urlencode(params)}"
|
|
|
|
return RedirectResponse(url=auth_url)
|
|
|
|
|
|
def setup_oauth_fake_dynamic_register_endpoint(
|
|
app: Annotated[FastAPI, Doc("The FastAPI app instance")],
|
|
client_id: Annotated[str, Doc("The client ID of the pre-registered client")],
|
|
client_secret: Annotated[str, Doc("The client secret of the pre-registered client")],
|
|
path: Annotated[str, Doc("The path to mount the register endpoint at")] = "/oauth/register",
|
|
include_in_schema: Annotated[bool, Doc("Whether to include the register endpoint in your OpenAPI docs")] = False,
|
|
):
|
|
"""
|
|
A proxy for dynamic client registration endpoint.
|
|
|
|
In MCP 2025-03-26 Spec, it is recommended to support OAuth Dynamic Client Registration (RFC 7591).
|
|
Furthermore, `npx mcp-remote` which is the current de-facto client that supports MCP's up-to-date spec,
|
|
requires this endpoint to be present.
|
|
|
|
But, this is an overcomplication for most use cases.
|
|
|
|
So instead of actually implementing dynamic client registration, we just echo back the pre-registered
|
|
client ID and secret.
|
|
|
|
Use this if you don't need dynamic client registration, or if your OAuth provider doesn't support it.
|
|
"""
|
|
|
|
@app.post(
|
|
path,
|
|
response_model=ClientRegistrationResponse,
|
|
include_in_schema=include_in_schema,
|
|
)
|
|
async def oauth_register_proxy(request: ClientRegistrationRequest) -> ClientRegistrationResponse:
|
|
client_response = ClientRegistrationResponse(
|
|
client_name=request.client_name or "MCP Server", # Name doesn't really affect functionality
|
|
client_id=client_id,
|
|
client_secret=client_secret,
|
|
redirect_uris=request.redirect_uris, # Just echo back their requested URIs
|
|
grant_types=request.grant_types or ["authorization_code"],
|
|
token_endpoint_auth_method=request.token_endpoint_auth_method or "none",
|
|
)
|
|
return client_response
|