From 0e6062bf164a988cabbe2932ab1a7909fc025f69 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Fri, 10 Apr 2026 14:33:09 -0600 Subject: [PATCH 1/2] Allow user-provided OAuth URL for LLM discovery --- dash/_configs.py | 1 + dash/dash.py | 10 ++++++- dash/mcp/_server.py | 73 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 82 insertions(+), 2 deletions(-) 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..3ce020b83f 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,73 @@ 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 OAuth metadata endpoint and auth gate for MCP. + + Serves RFC 9728 Protected Resource Metadata so MCP clients can + discover the authorization server, and returns 401 with + WWW-Authenticate for unauthenticated requests to the MCP endpoint. + """ + 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) + + @app.server.before_request + def _mcp_require_auth(): + if request.path != app.config.routes_pathname_prefix + mcp_path: + return None + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + return None + resource_metadata_url = _url_from_path(well_known_path) + return Response( + json.dumps({"error": "unauthorized"}), + status=401, + content_type="application/json", + headers={ + "WWW-Authenticate": ( + f'Bearer resource_metadata="{resource_metadata_url}"' + ), + }, + ) + + logger.info("MCP OAuth 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 +187,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, From 9fe863daa09bfb8c85ef4d9419cebcd25e4ec7d3 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Thu, 30 Apr 2026 18:08:52 -0600 Subject: [PATCH 2/2] Remove all request validation from Dash --- dash/mcp/_server.py | 32 ++++++++------------------------ 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/dash/mcp/_server.py b/dash/mcp/_server.py index 3ce020b83f..97a91d429d 100644 --- a/dash/mcp/_server.py +++ b/dash/mcp/_server.py @@ -63,11 +63,11 @@ def _url_from_path(*parts: str) -> str: def _setup_mcp_oauth(app: Dash, mcp_path: str, mcp_authorization_server: str) -> None: - """Register OAuth metadata endpoint and auth gate for MCP. + """Register RFC 9728 Protected Resource Metadata endpoint for MCP. - Serves RFC 9728 Protected Resource Metadata so MCP clients can - discover the authorization server, and returns 401 with - WWW-Authenticate for unauthenticated requests to the MCP endpoint. + 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) @@ -88,26 +88,10 @@ def _serve_resource_metadata() -> Response: # pylint: disable-next=protected-access app._add_url(well_known_path.lstrip("/"), _serve_resource_metadata) - @app.server.before_request - def _mcp_require_auth(): - if request.path != app.config.routes_pathname_prefix + mcp_path: - return None - auth_header = request.headers.get("Authorization", "") - if auth_header.startswith("Bearer "): - return None - resource_metadata_url = _url_from_path(well_known_path) - return Response( - json.dumps({"error": "unauthorized"}), - status=401, - content_type="application/json", - headers={ - "WWW-Authenticate": ( - f'Bearer resource_metadata="{resource_metadata_url}"' - ), - }, - ) - - logger.info("MCP OAuth enabled, authorization server: %s", mcp_authorization_server) + logger.info( + "MCP OAuth discovery enabled, authorization server: %s", + mcp_authorization_server, + ) def enable_mcp_server(