diff --git a/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py b/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py index bac15f0de98..8ec32ce88ed 100644 --- a/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py +++ b/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py @@ -98,6 +98,7 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import ( encrypt_value_helper, ) from litellm.proxy.common_utils.html_forms.native_client_consent import ( + render_mcp_connection_consent_page, render_native_client_consent_page, ) from litellm.types.mcp_server.mcp_server_manager import MCPServer @@ -1668,8 +1669,6 @@ async def authorize_connection( resource: str | None, scope: str | None, ) -> Response: - from html import escape - rejected: Final = _rejected_authorize_request( client_id, redirect_uri, state, code_challenge, code_challenge_method, response_type ) @@ -1700,23 +1699,23 @@ async def authorize_connection( exp=int(datetime.now(timezone.utc).timestamp()) + CONNECT_FLOW_TTL_SECONDS, ) action: Final = f"{get_request_base_url(request)}/authorize/connection/complete" + style_nonce: Final = secrets.token_urlsafe(18) response: Final = HTMLResponse( - '' - 'Authorize MCP connection' - "

Authorize MCP connection

" - f"

{escape(client.client_name or 'MCP client')} wants access to " - f"{escape(server.name)}.

Return address: {escape(redirect_uri)}

" - f"

Requested permissions: {escape(scopes or 'provider defaults')}

" - "

Only approve if you started this connection. You will continue to the provider to sign in.

" - f'
' - ' ' - "
", + render_mcp_connection_consent_page( + client_name=client.client_name or "MCP client", + server_name=server.name, + scopes=tuple(scopes.split()), + redirect_uri=redirect_uri, + flow_handle=handle, + complete_url=action, + style_nonce=style_nonce, + ), headers=MappingProxyType( { **TOKEN_NO_CACHE_HEADERS, "Referrer-Policy": "no-referrer", "X-Frame-Options": "DENY", - "Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'", + "Content-Security-Policy": f"default-src 'none'; style-src 'nonce-{style_nonce}'; frame-ancestors 'none'", } ), ) diff --git a/litellm/proxy/common_utils/html_forms/native_client_consent.py b/litellm/proxy/common_utils/html_forms/native_client_consent.py index dac92c4e787..6524f6a83cf 100644 --- a/litellm/proxy/common_utils/html_forms/native_client_consent.py +++ b/litellm/proxy/common_utils/html_forms/native_client_consent.py @@ -4,6 +4,37 @@ from typing import Final from litellm.constants import CLI_JWT_EXPIRATION_HOURS +_CONSENT_STYLES: Final = """ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background-color: #f8fafc; + margin: 0; + padding: 20px; + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + color: #1e293b; +} +.container { + background-color: #fff; + padding: 40px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + width: 450px; + max-width: 100%; +} +h1 { margin: 0 0 16px; font-size: 24px; font-weight: 600; } +p { margin: 0 0 12px; line-height: 1.5; } +code { background: #f1f5f9; padding: 2px 6px; border-radius: 4px; } +label { display: block; margin: 16px 0 6px; font-weight: 600; } +select { width: 100%; padding: 8px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 14px; } +.actions { display: flex; gap: 12px; margin-top: 24px; } +button { flex: 1; padding: 10px; border-radius: 6px; font-size: 15px; cursor: pointer; border: 1px solid #cbd5e1; } +.approve { background: #2563eb; color: #fff; border-color: #2563eb; } +.deny { background: #fff; color: #1e293b; } +""" + def render_native_client_consent_page( *, @@ -25,34 +56,7 @@ def render_native_client_consent_page( Authorize CLI access - LiteLLM @@ -89,3 +93,73 @@ def _team_field(teams: Sequence[tuple[str, str]]) -> str: return ( f'' ) + + +def render_mcp_connection_consent_page( + *, + client_name: str, + server_name: str, + scopes: Sequence[str], + redirect_uri: str, + flow_handle: str, + complete_url: str, + style_nonce: str, +) -> str: + permissions: Final = ( + '" + if scopes + else '

Default permissions configured by the server.

' + ) + return f""" + + + + + +Connect to {escape(server_name)} - LiteLLM + + + +
+
LiteLLM
+

Connect to {escape(server_name)}

+

{escape(client_name)} is requesting access through LiteLLM.

+
+

Requested permissions

+{permissions} +
+
+

Return to client

+{escape(redirect_uri)} +
+

Only continue if you started this connection and recognize the client and return address.

+
+ +
+ + +
+
+

Next, authorize access on the server's sign-in page.

+
+ + +""" diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index fbe82f07674..1d980746357 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -12422,6 +12422,7 @@ def keyed_oauth_client(monkeypatch): def _start_keyed_oauth(harness): + import re from urllib.parse import parse_qs, urlparse client = harness.client @@ -12461,6 +12462,13 @@ def _start_keyed_oauth(harness): assert "http://localhost:33418/callback" in consent.text assert "read:user" in consent.text assert "/sso/" not in consent.text + style_nonce = re.search(r'