diff --git a/dash/_configs.py b/dash/_configs.py index cfc5552986..b86bba46e2 100644 --- a/dash/_configs.py +++ b/dash/_configs.py @@ -35,6 +35,7 @@ def load_dash_env_vars(): "DASH_MCP_ENABLED", "DASH_MCP_PATH", "DASH_MCP_EXPOSE_DOCSTRINGS", + "DASH_MCP_AUTHORIZATION_SERVER", "HOST", "PORT", ) diff --git a/dash/dash.py b/dash/dash.py index 82b673217f..d4d77f4ea2 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -486,6 +486,7 @@ def __init__( # pylint: disable=too-many-statements enable_mcp: Optional[bool] = None, mcp_path: Optional[str] = None, mcp_expose_docstrings: Optional[bool] = None, + mcp_authorization_server: Optional[str] = None, **obsolete, ): @@ -605,6 +606,9 @@ def __init__( # pylint: disable=too-many-statements self._mcp_path = ( _mcp_path.lstrip("/") if isinstance(_mcp_path, str) else _mcp_path ) + self._mcp_authorization_server = get_combined_config( + "mcp_authorization_server", mcp_authorization_server + ) # list of dependencies - this one is used by the back end for dispatching self.callback_map: dict = {} @@ -833,7 +837,11 @@ def _setup_routes(self): ) try: - enable_mcp_server(self, self._mcp_path) + enable_mcp_server( + self, + self._mcp_path, + mcp_authorization_server=self._mcp_authorization_server, + ) except Exception as e: # pylint: disable=broad-exception-caught self._enable_mcp = False self.logger.warning( diff --git a/dash/mcp/_server.py b/dash/mcp/_server.py index d4f91ddd48..97a91d429d 100644 --- a/dash/mcp/_server.py +++ b/dash/mcp/_server.py @@ -8,7 +8,9 @@ import json import logging +from functools import reduce from typing import TYPE_CHECKING, Any +from urllib.parse import urljoin from flask import Response, request from mcp.types import ( @@ -46,7 +48,57 @@ logger = logging.getLogger(__name__) -def enable_mcp_server(app: Dash, mcp_path: str) -> None: +def _url_from_path(*parts: str) -> str: + """Build an absolute URL by joining path parts onto the current request origin. + + Behind a reverse proxy, TLS terminates at the proxy so + ``request.scheme`` reports HTTP even when the client connected + over HTTPS. Use HTTPS unless running on localhost. + """ + host = request.host + is_localhost = host.startswith("localhost") or host.startswith("127.0.0.1") + scheme = "http" if is_localhost else "https" + path = reduce(urljoin, parts, "/") + return f"{scheme}://{host}{path}" + + +def _setup_mcp_oauth(app: Dash, mcp_path: str, mcp_authorization_server: str) -> None: + """Register RFC 9728 Protected Resource Metadata endpoint for MCP. + + Serves discovery metadata so MCP clients can find the authorization + server. Auth enforcement is the responsibility of the hosting platform + (e.g. Plotly Cloud gateway, Dash Embedded, or a reverse proxy). + """ + well_known_path = urljoin("/.well-known/oauth-protected-resource/", mcp_path) + + def _serve_resource_metadata() -> Response: + return Response( + json.dumps( + { + "resource": _url_from_path( + app.config.requests_pathname_prefix, mcp_path + ), + "authorization_servers": [mcp_authorization_server], + "bearer_methods_supported": ["header"], + } + ), + content_type="application/json", + ) + + # pylint: disable-next=protected-access + app._add_url(well_known_path.lstrip("/"), _serve_resource_metadata) + + logger.info( + "MCP OAuth discovery enabled, authorization server: %s", + mcp_authorization_server, + ) + + +def enable_mcp_server( + app: Dash, + mcp_path: str, + mcp_authorization_server: str | None = None, +) -> None: """Add MCP routes to a Dash/Flask app.""" app.mcp_decorated_functions = dict(MCP_DECORATED_FUNCTIONS) @@ -119,6 +171,9 @@ def _handle_delete() -> Response: mcp_path, with_app_context_factory(mcp_handler, app), ["GET", "POST", "DELETE"] ) + if mcp_authorization_server: + _setup_mcp_oauth(app, mcp_path, mcp_authorization_server) + logger.info( "MCP routes registered at %s%s", app.config.routes_pathname_prefix,