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 @@ -36,6 +36,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 @@ -489,6 +489,7 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches
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 @@ -609,6 +610,9 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches
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 @@ -829,7 +833,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
61 changes: 60 additions & 1 deletion dash/mcp/_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
import json
import logging
import uuid
from functools import reduce
from typing import TYPE_CHECKING, Any
from urllib.parse import urljoin

from mcp.types import (
LATEST_PROTOCOL_VERSION,
Expand Down Expand Up @@ -45,7 +47,61 @@
logger = logging.getLogger(__name__)


def enable_mcp_server(app: Dash, mcp_path: str) -> None:
def _url_from_path(app: Dash, *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
the scheme may report HTTP even when the client connected
over HTTPS. Use HTTPS unless running on localhost.
"""
from urllib.parse import urlparse # pylint: disable=import-outside-toplevel

adapter = app.backend.request_adapter()
parsed = urlparse(adapter.url)
host = parsed.netloc
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():
return app.backend.make_response(
json.dumps(
{
"resource": _url_from_path(
app, 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 app."""

app.mcp_decorated_functions = dict(MCP_DECORATED_FUNCTIONS)
Expand Down Expand Up @@ -185,6 +241,9 @@ def _handle_not_allowed():
)
app.routes.append(mcp_url)

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