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
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
Loading
Loading