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
70 changes: 69 additions & 1 deletion dash/mcp/_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
import json
import logging
import os
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 @@ -47,7 +49,70 @@
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).
"""
if app.config.requests_pathname_prefix != "/":
raise ValueError(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Raising here means that the error will get caught in the try/except block in dash.py. Would it be better to raise earlier when first checking on config options?

"`mcp_authorization_server` cannot be used in conjunction with "
"`requests_pathname_prefix`. "
"Authorization must be implemented at the platform level "
"(see https://www.rfc-editor.org/rfc/rfc9728#section-3). "
"Remove the mcp_authorization_server parameter."
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be worth it to guide the user a bit more on how to fix the issue with a URL template or something. At a minimum, I'd update the last sentence to be a little less of a command.

Suggested change
"Remove the mcp_authorization_server parameter."
"Remove the `mcp_authorization_server` or `requests_pathname_prefix` parameter to avoid this error."

)

well_known_path = urljoin("/.well-known/oauth-protected-resource/", mcp_path)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you double check the URL construction? It looks like the final result might not be correct. Shouldn't the pathname prefix be included before the MCP path?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed offline, I am now raising when mcp_authorization_server is used in conjunction with requests_pathname_prefix since we cannot accommodate both.


def _serve_resource_metadata():
return app.backend.make_response(
json.dumps(
{
"resource": _url_from_path(
app, app.config.requests_pathname_prefix, mcp_path
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prefix will only ever be "/" here, right?

),
"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 @@ -207,6 +272,9 @@ def _handle_not_allowed():
)
app.routes.append(mcp_url)

if mcp_authorization_server:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this check happen before registering the routes? If the auth server has been configured, then the routes get registered but MCP can't be enabled. It might be better to fail earlier.

_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