Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dash/_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
Expand Down
10 changes: 9 additions & 1 deletion dash/dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
):

Expand Down Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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(
Expand Down
57 changes: 56 additions & 1 deletion dash/mcp/_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Loading