From 99d77357b14e64a55af37442a00bbd6a48a60247 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 2 May 2025 16:47:05 +0100 Subject: [PATCH 1/8] first cut at a per-client consent flow --- .../simple-auth/mcp_simple_auth/server.py | 10 + src/mcp/server/auth/handlers/authorize.py | 21 ++ src/mcp/server/auth/handlers/consent.py | 212 ++++++++++++++++++ src/mcp/server/auth/provider.py | 21 ++ src/mcp/server/auth/routes.py | 9 + 5 files changed, 273 insertions(+) create mode 100644 src/mcp/server/auth/handlers/consent.py diff --git a/examples/servers/simple-auth/mcp_simple_auth/server.py b/examples/servers/simple-auth/mcp_simple_auth/server.py index 2f1e4086f..b5ffbf327 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/server.py +++ b/examples/servers/simple-auth/mcp_simple_auth/server.py @@ -73,6 +73,8 @@ def __init__(self, settings: ServerSettings): # Store GitHub tokens with MCP tokens using the format: # {"mcp_token": "github_token"} self.token_mapping: dict[str, str] = {} + # Track which clients have been granted consent + self.client_consent: dict[str, bool] = {} async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: """Get OAuth client information.""" @@ -81,6 +83,14 @@ async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: async def register_client(self, client_info: OAuthClientInformationFull): """Register a new OAuth client.""" self.clients[client_info.client_id] = client_info + + async def has_client_consent(self, client: OAuthClientInformationFull) -> bool: + """Check if a client has already provided consent.""" + return self.client_consent.get(client.client_id, False) + + async def grant_client_consent(self, client: OAuthClientInformationFull) -> None: + """Grant consent for a client.""" + self.client_consent[client.client_id] = True async def authorize( self, client: OAuthClientInformationFull, params: AuthorizationParams diff --git a/src/mcp/server/auth/handlers/authorize.py b/src/mcp/server/auth/handlers/authorize.py index 8f3768908..d82c26060 100644 --- a/src/mcp/server/auth/handlers/authorize.py +++ b/src/mcp/server/auth/handlers/authorize.py @@ -1,6 +1,7 @@ import logging from dataclasses import dataclass from typing import Any, Literal +from urllib.parse import urlencode from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, RootModel, ValidationError from starlette.datastructures import FormData, QueryParams @@ -218,6 +219,26 @@ async def error_response( ) try: + # Check if client has already consented + has_consent = await self.provider.has_client_consent(client) + + if not has_consent: + # Redirect to consent page with necessary parameters + consent_url = "/consent" + "?" + urlencode({ + "client_id": auth_request.client_id, + "redirect_uri": str(redirect_uri), + "state": state or "", + "scopes": " ".join(scopes) if scopes else "", + "code_challenge": auth_request.code_challenge, + "response_type": auth_request.response_type, + }) + + return RedirectResponse( + url=consent_url, + status_code=302, + headers={"Cache-Control": "no-store"}, + ) + # Let the provider pick the next URI to redirect to return RedirectResponse( url=await self.provider.authorize( diff --git a/src/mcp/server/auth/handlers/consent.py b/src/mcp/server/auth/handlers/consent.py new file mode 100644 index 000000000..58eb821fd --- /dev/null +++ b/src/mcp/server/auth/handlers/consent.py @@ -0,0 +1,212 @@ +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlencode + +from starlette.requests import Request +from starlette.responses import HTMLResponse, RedirectResponse, Response + +from mcp.server.auth.handlers.authorize import AuthorizationHandler +from mcp.server.auth.provider import OAuthAuthorizationServerProvider + + +@dataclass +class ConsentHandler: + provider: OAuthAuthorizationServerProvider[Any, Any, Any] + + async def handle(self, request: Request) -> Response: + # This handles both showing the consent form (GET) and processing consent (POST) + + if request.method == "GET": + # Show consent form + return await self._show_consent_form(request) + elif request.method == "POST": + # Process consent + return await self._process_consent(request) + else: + return HTMLResponse(status_code=405, content="Method not allowed") + + async def _show_consent_form(self, request: Request) -> HTMLResponse: + client_id = request.query_params.get("client_id", "") + redirect_uri = request.query_params.get("redirect_uri", "") + state = request.query_params.get("state", "") + scopes = request.query_params.get("scopes", "") + code_challenge = request.query_params.get("code_challenge", "") + response_type = request.query_params.get("response_type", "") + + # TODO: get this passed in + target_url = "/consent" + + # Create a simple consent form + + html_content = f""" + + + + Authorization Required + + + + + + + + +""" + return HTMLResponse(content=html_content) + + async def _process_consent(self, request: Request) -> RedirectResponse: + form_data = await request.form() + action = form_data.get("action") + + if action == "approve": + # Grant consent and continue with authorization + client_id = form_data.get("client_id") + if client_id: + client = await self.provider.get_client(client_id) + if client: + await self.provider.grant_client_consent(client) + + # Redirect back to authorization endpoint with original parameters + auth_params = urlencode({ + k: v for k, v in form_data.items() + if k in ["client_id", "redirect_uri", "state", "scopes", "code_challenge", "response_type"] + }) + + return RedirectResponse( + # TODO: get this passed in + url=f"/authorize?{auth_params}", + status_code=302, + headers={"Cache-Control": "no-store"}, + ) + else: + # User denied consent + redirect_uri = form_data.get("redirect_uri") + state = form_data.get("state") + + error_params = { + "error": "access_denied", + "error_description": "User denied the authorization request" + } + if state: + error_params["state"] = state + + if redirect_uri: + return RedirectResponse( + url=f"{redirect_uri}?{urlencode(error_params)}", + status_code=302, + headers={"Cache-Control": "no-store"}, + ) + else: + return HTMLResponse( + status_code=400, + content=f"Access denied: {error_params['error_description']}" + ) + + def _format_scopes(self, scopes: str) -> str: + if not scopes: + return "

No specific permissions requested

" + + scope_list = scopes.split() + if not scope_list: + return "

No specific permissions requested

" + + scope_html = "" + for scope in scope_list: + scope_html += f'
{scope}
' + + return scope_html diff --git a/src/mcp/server/auth/provider.py b/src/mcp/server/auth/provider.py index be1ac1dbc..8162802ea 100644 --- a/src/mcp/server/auth/provider.py +++ b/src/mcp/server/auth/provider.py @@ -174,6 +174,27 @@ async def authorize( """ ... + async def has_client_consent(self, client: OAuthClientInformationFull) -> bool: + """ + Check if a client has already provided consent. + + Args: + client: The client to check consent status for. + + Returns: + True if the client has provided consent, False otherwise. + """ + ... + + async def grant_client_consent(self, client: OAuthClientInformationFull) -> None: + """ + Grant consent for a client. + + Args: + client: The client to grant consent for. + """ + ... + async def load_authorization_code( self, client: OAuthClientInformationFull, authorization_code: str ) -> AuthorizationCodeT | None: diff --git a/src/mcp/server/auth/routes.py b/src/mcp/server/auth/routes.py index 4c56ca247..a401507fb 100644 --- a/src/mcp/server/auth/routes.py +++ b/src/mcp/server/auth/routes.py @@ -9,6 +9,7 @@ from starlette.types import ASGIApp from mcp.server.auth.handlers.authorize import AuthorizationHandler +from mcp.server.auth.handlers.consent import ConsentHandler from mcp.server.auth.handlers.metadata import MetadataHandler from mcp.server.auth.handlers.register import RegistrationHandler from mcp.server.auth.handlers.revoke import RevocationHandler @@ -49,6 +50,7 @@ def validate_issuer_url(url: AnyHttpUrl): TOKEN_PATH = "/token" REGISTRATION_PATH = "/register" REVOCATION_PATH = "/revoke" +CONSENT_PATH = "/consent" def cors_middleware( @@ -113,6 +115,13 @@ def create_auth_routes( ), methods=["POST", "OPTIONS"], ), + Route( + CONSENT_PATH, + # do not allow CORS for consent endpoint; + # clients should just redirect to this + endpoint=ConsentHandler(provider).handle, + methods=["GET", "POST"], + ), ] if client_registration_options.enabled: From 15ecf7319a77c3137ed5aec89703f115580aaa95 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 7 May 2025 10:46:59 +0100 Subject: [PATCH 2/8] add consent --- src/mcp/server/auth/handlers/consent.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/mcp/server/auth/handlers/consent.py b/src/mcp/server/auth/handlers/consent.py index 58eb821fd..49b4bf3a4 100644 --- a/src/mcp/server/auth/handlers/consent.py +++ b/src/mcp/server/auth/handlers/consent.py @@ -33,6 +33,13 @@ async def _show_consent_form(self, request: Request) -> HTMLResponse: code_challenge = request.query_params.get("code_challenge", "") response_type = request.query_params.get("response_type", "") + # Get client info to display client_name + client_name = client_id # Default to client_id if we can't get the client + if client_id: + client = await self.provider.get_client(client_id) + if client and hasattr(client, 'client_name'): + client_name = client.client_name + # TODO: get this passed in target_url = "/consent" @@ -118,9 +125,10 @@ async def _show_consent_form(self, request: Request) -> HTMLResponse: