Skip to content
Open
Show file tree
Hide file tree
Changes from 13 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 @@ -33,6 +33,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 @@ -486,6 +486,9 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches
websocket_callbacks: Optional[bool] = False,
websocket_allowed_origins: Optional[List[str]] = None,
websocket_inactivity_timeout: Optional[int] = 300000,
enable_mcp: Optional[bool] = None,
mcp_path: Optional[str] = None,
mcp_expose_docstrings: Optional[bool] = None,
**obsolete,
):

Expand Down Expand Up @@ -567,6 +570,9 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches
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 @@ -597,11 +603,19 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches
# 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 @@ -809,6 +823,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,
)

def setup_apis(self):
"""
Register API endpoints for all callbacks defined using `dash.callback`.
Expand Down Expand Up @@ -2452,6 +2481,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
Comment on lines +49 to +51
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.

Do you need to return the wrapping function here?

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.

This is because the decorator supports optional arguments:
func is None when used with arguments (@mcp_enabled(name="foo"))
It's not None when used bare (@mcp_enabled)

Loading
Loading