Skip to content
3 changes: 3 additions & 0 deletions dash/_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ def load_dash_env_vars():
"DASH_DISABLE_VERSION_CHECK",
"DASH_PRUNE_ERRORS",
"DASH_COMPRESS",
"DASH_MCP_ENABLED",
"DASH_MCP_PATH",
"DASH_MCP_EXPOSE_DOCSTRINGS",
"HOST",
"PORT",
)
Expand Down
2 changes: 1 addition & 1 deletion dash/_layout_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ def _collect_components(value: Any) -> list[Component]:
if isinstance(value, Component):
return [value]
if isinstance(value, (list, tuple)):
return [item for item in value if isinstance(item, (Component, list, tuple))]
return [item for item in value if isinstance(item, Component)]
return []


Expand Down
35 changes: 35 additions & 0 deletions dash/dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,9 @@ def __init__( # pylint: disable=too-many-statements
health_endpoint: Optional[str] = None,
csrf_token_name: str = "_csrf_token",
csrf_header_name: str = "X-CSRFToken",
enable_mcp: Optional[bool] = None,
mcp_path: Optional[str] = None,
mcp_expose_docstrings: Optional[bool] = None,
**obsolete,
):

Expand Down Expand Up @@ -563,6 +566,9 @@ def __init__( # pylint: disable=too-many-statements
hide_all_callbacks=False,
csrf_token_name=csrf_token_name,
csrf_header_name=csrf_header_name,
mcp_expose_docstrings=get_combined_config(
"mcp_expose_docstrings", mcp_expose_docstrings, False
),
)
self.config.set_read_only(
[
Expand Down Expand Up @@ -593,6 +599,13 @@ def __init__( # pylint: disable=too-many-statements
# keep title as a class property for backwards compatibility
self.title = title

# MCP (Model Context Protocol) configuration
self._enable_mcp = get_combined_config("mcp_enabled", enable_mcp, False)
_mcp_path = get_combined_config("mcp_path", mcp_path, "_mcp")
self._mcp_path = (
_mcp_path.lstrip("/") if isinstance(_mcp_path, str) else _mcp_path
)

# list of dependencies - this one is used by the back end for dispatching
self.callback_map: dict = {}
# same deps as a list to catch duplicate outputs, and to send to the front end
Expand Down Expand Up @@ -813,6 +826,21 @@ def _setup_routes(self):
hook.data["methods"],
)

if self._enable_mcp:
from .mcp import ( # pylint: disable=import-outside-toplevel
enable_mcp_server,
)

try:
enable_mcp_server(self, self._mcp_path)
except Exception as e: # pylint: disable=broad-exception-caught
self._enable_mcp = False
self.logger.warning(
"MCP server could not be started at '%s': %s",
self._mcp_path,
e,
)

# catch-all for front-end routes, used by dcc.Location
self._add_url("<path:path>", self.index)

Expand Down Expand Up @@ -2548,6 +2576,13 @@ def verify_url_part(served_part, url_part, part_name):

if not jupyter_dash or not jupyter_dash.in_ipython:
self.logger.info("Dash is running on %s://%s%s%s\n", *display_url)
if self._enable_mcp:
self.logger.info(
" * MCP available at %s://%s%s%s%s\n",
*display_url[:3],
self.config.routes_pathname_prefix,
self._mcp_path,
)

if self.config.extra_hot_reload_paths:
extra_files = flask_run_options["extra_files"] = []
Expand Down
7 changes: 7 additions & 0 deletions dash/mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Dash MCP (Model Context Protocol) server integration."""

from dash.mcp._server import enable_mcp_server

__all__ = [
"enable_mcp_server",
]
197 changes: 197 additions & 0 deletions dash/mcp/_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
"""Flask route setup, Streamable HTTP transport, and MCP message handling."""

# pylint: disable=cyclic-import
# The MCP server imports dash primitives to dispatch callbacks, and dash
# lazy-imports this module to wire the MCP endpoint. Cycle is managed here.

from __future__ import annotations

import json
import logging
from typing import TYPE_CHECKING, Any

from flask import Response, request
from mcp.types import (
LATEST_PROTOCOL_VERSION,
ErrorData,
Implementation,
InitializeResult,
JSONRPCError,
JSONRPCResponse,
ResourcesCapability,
ServerCapabilities,
ToolsCapability,
)

from dash import get_app
from dash._get_app import with_app_context_factory
from dash.mcp.primitives import (
call_tool,
list_resource_templates,
list_resources,
list_tools,
read_resource,
)
from dash.mcp.primitives.tools.callback_adapter_collection import (
CallbackAdapterCollection,
)
from dash.mcp.types import MCPError
from dash.version import __version__

if TYPE_CHECKING:
from dash import Dash

logger = logging.getLogger(__name__)


def enable_mcp_server(app: Dash, mcp_path: str) -> None:
"""Add MCP routes to a Dash/Flask app."""
# -- Streamable HTTP endpoint --------------------------------------------

def mcp_handler() -> Response:
if request.method == "POST":
return _handle_post()
if request.method == "GET":
return _handle_get()
if request.method == "DELETE":
return _handle_delete()
return Response(
json.dumps({"error": "Method not allowed"}),
content_type="application/json",
status=405,
)

def _handle_get() -> Response:
# MCP spec allows servers to opt out of GET-initiated SSE streams
# by returning 405. We don't push server-initiated events.
return Response(
json.dumps({"error": "Method not allowed"}),
content_type="application/json",
status=405,
)

def _handle_post() -> Response:
content_type = request.content_type or ""
if "application/json" not in content_type:
return Response(
json.dumps({"error": "Content-Type must be application/json"}),
content_type="application/json",
status=415,
)

data = request.get_json(silent=True)
if data is None:
return Response(
json.dumps({"error": "Invalid JSON"}),
content_type="application/json",
status=400,
)

response_data = _process_mcp_message(data)

if response_data is None:
return Response("", status=202)

return Response(
json.dumps(response_data),
content_type="application/json",
status=200,
)

def _handle_delete() -> Response:
# No sessions to terminate — server is stateless.
return Response(
json.dumps({"error": "Method not allowed"}),
content_type="application/json",
status=405,
)

# -- Register routes -----------------------------------------------------

# pylint: disable-next=protected-access
app._add_url(
mcp_path, with_app_context_factory(mcp_handler, app), ["GET", "POST", "DELETE"]
)

logger.info(
"MCP routes registered at %s%s",
app.config.routes_pathname_prefix,
mcp_path,
)


def _handle_initialize() -> InitializeResult:
return InitializeResult(
protocolVersion=LATEST_PROTOCOL_VERSION,
capabilities=ServerCapabilities(
tools=ToolsCapability(listChanged=False),
resources=ResourcesCapability(),
),
serverInfo=Implementation(name="Plotly Dash", version=__version__),
instructions=(
"This is a Dash web application. "
"Dash apps are stateless: calling a tool executes "
"a callback and returns its result to you, but does "
"NOT update the user's browser. "
"Use tool results to answer questions about what "
"the app would produce for given inputs."
),
)


def _process_mcp_message(data: dict[str, Any]) -> dict[str, Any] | None:
"""
Process an MCP JSON-RPC message and return the response dict.

Returns ``None`` for notifications (no ``id`` field).
"""
method = data.get("method", "")
params = data.get("params", {}) or {}
_id = data.get("id")
request_id: str | int = _id if isinstance(_id, (str, int)) else ""

app = get_app()
if not hasattr(app, "mcp_callback_map"):
app.mcp_callback_map = CallbackAdapterCollection(app)

mcp_methods = {
"initialize": _handle_initialize,
"tools/list": list_tools,
"tools/call": lambda: call_tool(
params.get("name", ""), params.get("arguments", {})
),
"resources/list": list_resources,
"resources/templates/list": list_resource_templates,
"resources/read": lambda: read_resource(params.get("uri", "")),
}

try:
handler = mcp_methods.get(method)
if handler is None:
if method.startswith("notifications/"):
return None
raise ValueError(f"Unknown method: {method}")

result = handler()

response = JSONRPCResponse(
jsonrpc="2.0",
id=request_id,
result=result.model_dump(exclude_none=True, mode="json"),
)
return response.model_dump(exclude_none=True, mode="json")

except MCPError as e:
logger.error("MCP error: %s", e)
return JSONRPCError(
jsonrpc="2.0",
id=request_id,
error=ErrorData(code=e.code, message=str(e)),
).model_dump(exclude_none=True)
except Exception as e: # pylint: disable=broad-exception-caught
logger.error("MCP error: %s", e, exc_info=True)
return JSONRPCError(
jsonrpc="2.0",
id=request_id,
error=ErrorData(code=-32603, message=f"{type(e).__name__}: {e}"),
).model_dump(exclude_none=True)
17 changes: 17 additions & 0 deletions dash/mcp/primitives/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from .resources import (
list_resources,
list_resource_templates,
read_resource,
)
from .tools import (
call_tool,
list_tools,
)

__all__ = [
"call_tool",
"list_resources",
"list_resource_templates",
"list_tools",
"read_resource",
]
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
Resource,
TextResourceContents,
)
from pydantic import AnyUrl

from dash import get_app
from dash._utils import clean_property_name, split_callback_id
Expand All @@ -25,7 +26,7 @@ def get_resource(cls) -> Resource | None:
if not _get_clientside_callbacks():
return None
return Resource(
uri=cls.uri,
uri=AnyUrl(cls.uri),
name="dash_clientside_callbacks",
description=(
"Actions the user can take manually in the browser "
Expand All @@ -52,7 +53,7 @@ def read_resource(cls, uri: str = "") -> ReadResourceResult:
return ReadResourceResult(
contents=[
TextResourceContents(
uri=cls.uri,
uri=AnyUrl(cls.uri),
mimeType="application/json",
text=json.dumps(data, default=str),
)
Expand Down
7 changes: 4 additions & 3 deletions dash/mcp/primitives/resources/resource_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
Resource,
TextResourceContents,
)
from pydantic import AnyUrl

from dash import get_app
from dash._layout_utils import traverse
Expand All @@ -22,7 +23,7 @@ class ComponentsResource(MCPResourceProvider):
@classmethod
def get_resource(cls) -> Resource | None:
return Resource(
uri=cls.uri,
uri=AnyUrl(cls.uri),
name="dash_components",
description=(
"All components with IDs in the app layout. "
Expand All @@ -41,7 +42,7 @@ def read_resource(cls, uri: str = "") -> ReadResourceResult:
components = sorted(
[
{
"id": str(comp.id),
"id": str(getattr(comp, "id", None)),
"type": getattr(comp, "_type", type(comp).__name__),
}
for comp, _ in traverse(layout)
Expand All @@ -53,7 +54,7 @@ def read_resource(cls, uri: str = "") -> ReadResourceResult:
return ReadResourceResult(
contents=[
TextResourceContents(
uri=cls.uri,
uri=AnyUrl(cls.uri),
mimeType="application/json",
text=json.dumps(components),
)
Expand Down
5 changes: 3 additions & 2 deletions dash/mcp/primitives/resources/resource_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
Resource,
TextResourceContents,
)
from pydantic import AnyUrl

from dash import get_app
from dash._utils import to_json
Expand All @@ -20,7 +21,7 @@ class LayoutResource(MCPResourceProvider):
@classmethod
def get_resource(cls) -> Resource | None:
return Resource(
uri=cls.uri,
uri=AnyUrl(cls.uri),
name="dash_app_layout",
description=(
"Full component tree of the Dash app. "
Expand All @@ -35,7 +36,7 @@ def read_resource(cls, uri: str = "") -> ReadResourceResult:
return ReadResourceResult(
contents=[
TextResourceContents(
uri=cls.uri,
uri=AnyUrl(cls.uri),
mimeType="application/json",
text=to_json(app.get_layout()),
)
Expand Down
Loading
Loading