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
36 changes: 36 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,11 +599,19 @@ 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
self._callback_list: list = []
self.callback_api_paths: dict = {}
self.mcp_decorated_functions: dict = {}

# list of inline scripts
self._inline_scripts: list = []
Expand Down Expand Up @@ -813,6 +827,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 +2577,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
9 changes: 9 additions & 0 deletions dash/mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Dash MCP (Model Context Protocol) server integration."""

from dash.mcp._decorator import mcp_enabled
from dash.mcp._server import enable_mcp_server

__all__ = [
"enable_mcp_server",
"mcp_enabled",
]
51 changes: 51 additions & 0 deletions dash/mcp/_decorator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Decorator to expose plain Python functions as MCP tools."""

from __future__ import annotations

import functools
from typing import Any, Callable, Optional

from typing_extensions import TypedDict


class MCPToolRegistration(TypedDict):
fn: Callable[..., Any]
expose_docstring: Optional[bool]


MCP_DECORATED_FUNCTIONS: dict[str, MCPToolRegistration] = {}


def mcp_enabled(
func: Callable[..., Any] | None = None,
*,
name: str | None = None,
expose_docstring: Optional[bool] = None,
) -> Callable[..., Any]:
"""Mark a function as an MCP tool.

Supports both bare and parameterised usage::

@mcp_enabled
def my_tool(x: int) -> str: ...

@mcp_enabled(name="custom_name", expose_docstring=True)
def my_tool(x: int) -> str: ...
"""

def _wrap(fn: Callable[..., Any]) -> Callable[..., Any]:
tool_name = name if name else fn.__name__
MCP_DECORATED_FUNCTIONS[tool_name] = MCPToolRegistration(
fn=fn,
expose_docstring=expose_docstring,
)

@functools.wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> Any:
return fn(*args, **kwargs)

return wrapper

if func is not None:
return _wrap(func)
return _wrap
202 changes: 202 additions & 0 deletions dash/mcp/_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
"""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._decorator import MCP_DECORATED_FUNCTIONS
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."""

app.mcp_decorated_functions = dict(MCP_DECORATED_FUNCTIONS)
MCP_DECORATED_FUNCTIONS.clear()

# -- 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",
]
Loading
Loading