Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ This project adheres to [Semantic Versioning](https://semver.org/).

## [UNRELEASED]

## [4.3.0rc1] - Unreleased

## Added
- [#3796](https://github.com/plotly/dash/pull/3796) MCP: Add `configure_mcp_server()` to toggle which content the MCP server exposes (`include_layout`, `include_callbacks`, `include_clientside_callbacks`, `include_pages`, `expose_callback_docstrings`). Only the parameters explicitly passed are updated; omitted parameters retain their current value.

## Changed
- [#3796](https://github.com/plotly/dash/pull/3796) MCP: Remove the `mcp_expose_docstrings` `Dash()` constructor argument; callback docstring exposure is now controlled via `configure_mcp_server(expose_callback_docstrings=...)`.

## [4.3.0rc0] - 2026-05-21

## Added
Expand Down
6 changes: 3 additions & 3 deletions dash/_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ def callback(
hidden: Optional[bool] = None,
websocket: Optional[bool] = False,
persistent: Optional[bool] = False,
mcp_enabled: bool = True,
mcp_enabled: Optional[bool] = None,
mcp_expose_docstring: Optional[bool] = None,
**_kwargs,
) -> Callable[[Callable[Params, ReturnVar]], Callable[Params, ReturnVar]]:
Expand Down Expand Up @@ -300,7 +300,7 @@ def insert_callback(
hidden=None,
websocket=False,
persistent=False,
mcp_enabled=True,
mcp_enabled=None,
mcp_expose_docstring=None,
) -> str:
if prevent_initial_call is None:
Expand Down Expand Up @@ -709,7 +709,7 @@ def register_callback(
hidden=_kwargs.get("hidden", None),
websocket=_kwargs.get("websocket", False),
persistent=_kwargs.get("persistent", False),
mcp_enabled=_kwargs.get("mcp_enabled", True),
mcp_enabled=_kwargs.get("mcp_enabled", None),
mcp_expose_docstring=_kwargs.get("mcp_expose_docstring"),
)

Expand Down
1 change: 0 additions & 1 deletion dash/_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ def load_dash_env_vars():
"DASH_COMPRESS",
"DASH_MCP_ENABLED",
"DASH_MCP_PATH",
"DASH_MCP_EXPOSE_DOCSTRINGS",
"HOST",
"PORT",
)
Expand Down
5 changes: 1 addition & 4 deletions dash/dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -492,7 +492,6 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches
websocket_batch_delay: Optional[float] = 0.005,
enable_mcp: Optional[bool] = None,
mcp_path: Optional[str] = None,
mcp_expose_docstrings: Optional[bool] = None,
**obsolete,
):

Expand Down Expand Up @@ -574,9 +573,6 @@ 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 @@ -620,6 +616,7 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches
self._callback_list: list = []
self.callback_api_paths: dict = {}
self.mcp_decorated_functions: dict = {}
self.mcp_callback_map: Any = None
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.

Suggested change
self.mcp_callback_map: Any = None
self.mcp_callback_map: Optional["CallbackAdapterCollection"] = None

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 actually needs to be Any for now because of Python < 3.10.
CallbackAdapterCollection imports from the mcp module which cannot be installed on 3.8. Therefore, if we import it here for typing, it will break on older python versions.

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.

Stupid backward compatibility!


# list of inline scripts
self._inline_scripts: list = []
Expand Down
2 changes: 2 additions & 0 deletions dash/mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""Dash MCP (Model Context Protocol) server integration."""

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

__all__ = [
"configure_mcp_server",
"enable_mcp_server",
"mcp_enabled",
]
133 changes: 133 additions & 0 deletions dash/mcp/_configure.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""Public configuration API for the Dash MCP server."""

# pylint: disable=cyclic-import
# dash.dash lazy-imports dash.mcp inside _setup_routes(); pylint's static
# analysis treats it as a module-level import, producing a false cycle.

from __future__ import annotations

from typing import Optional

from dash import get_app
from dash.exceptions import AppNotFoundError
from dash.mcp.primitives.resources import _RESOURCE_PROVIDERS as MCP_RESOURCE_PROVIDERS
from dash.mcp.primitives.resources.resource_clientside_callbacks import (
ClientsideCallbacksResource,
)
from dash.mcp.primitives.resources.resource_components import ComponentsResource
from dash.mcp.primitives.resources.resource_layout import LayoutResource
from dash.mcp.primitives.resources.resource_page_layout import PageLayoutResource
from dash.mcp.primitives.resources.resource_pages import PagesResource
from dash.mcp.primitives.tools import _TOOL_PROVIDERS as MCP_TOOL_PROVIDERS
from dash.mcp.primitives.tools.tool_get_dash_component import GetDashComponentTool
from dash.mcp.primitives.tools.tools_callbacks import CallbackTools

_ALL_MCP_RESOURCE_PROVIDERS = list(MCP_RESOURCE_PROVIDERS)
_ALL_MCP_TOOL_PROVIDERS = list(MCP_TOOL_PROVIDERS)

# Membership groupings (order-independent): which providers each toggle
# controls. The exposed order is owned solely by the registry lists.
_LAYOUT_RESOURCES = {LayoutResource, ComponentsResource}
_CLIENTSIDE_CALLBACK_RESOURCES = {ClientsideCallbacksResource}
_PAGE_RESOURCES = {PagesResource, PageLayoutResource}
_LAYOUT_TOOLS = {GetDashComponentTool}

_DEFAULT_CONFIG = {
"include_layout": True,
"include_callbacks": True,
"include_clientside_callbacks": True,
"include_pages": True,
"expose_callback_docstrings": False,
}
_current_config = dict(_DEFAULT_CONFIG)


def configure_mcp_server(
*,
include_layout: Optional[bool] = None,
include_callbacks: Optional[bool] = None,
include_clientside_callbacks: Optional[bool] = None,
include_pages: Optional[bool] = None,
expose_callback_docstrings: Optional[bool] = None,
) -> None:
"""
Configure which content the Dash MCP server exposes.

Only the parameters that are explicitly passed are updated; any parameter
that is omitted keeps its current value. On the first call, unset values
take their defaults (all content included except callback docstrings).

:param include_layout: Expose ``dash://layout``, ``dash://components``,
and the ``get_dash_component`` tool. Defaults to ``True``.
:param include_callbacks: When ``True`` (default), all callbacks are
included; ``mcp_enabled=False`` on a ``@callback`` opts it out.
When ``False``, no callbacks are included by default;
``mcp_enabled=True`` opts a specific callback in.
:param include_clientside_callbacks: Expose the
``dash://clientside-callbacks`` resource. Defaults to ``True``.
:param include_pages: Expose ``dash://pages`` and
``dash://page-layout/{path}``. Defaults to ``True``.
:param expose_callback_docstrings: Include callback docstrings in
tool descriptions. Defaults to ``False``.

Example — expose only ``@mcp_enabled``-decorated functions::

from dash.mcp import configure_mcp_server

configure_mcp_server(
include_layout=False,
include_callbacks=False,
include_clientside_callbacks=False,
include_pages=False,
)
"""
try:
if get_app().backend.has_request_context():
raise RuntimeError("MCP server can't be configured within a callback")
except AppNotFoundError:
pass

passed = {
"include_layout": include_layout,
"include_callbacks": include_callbacks,
"include_clientside_callbacks": include_clientside_callbacks,
"include_pages": include_pages,
"expose_callback_docstrings": expose_callback_docstrings,
}
_current_config.update(
{key: value for key, value in passed.items() if value is not None}
)

CallbackTools.callbacks_mcp_enabled_by_default = _current_config[
"include_callbacks"
]
CallbackTools.expose_docstrings_by_default = _current_config[
"expose_callback_docstrings"
]

excluded_resources: set = set()
if not _current_config["include_layout"]:
excluded_resources |= _LAYOUT_RESOURCES
if not _current_config["include_clientside_callbacks"]:
excluded_resources |= _CLIENTSIDE_CALLBACK_RESOURCES
if not _current_config["include_pages"]:
excluded_resources |= _PAGE_RESOURCES
MCP_RESOURCE_PROVIDERS[:] = [
resource
for resource in _ALL_MCP_RESOURCE_PROVIDERS
if resource not in excluded_resources
]

excluded_tools: set = set()
if not _current_config["include_layout"]:
excluded_tools |= _LAYOUT_TOOLS
MCP_TOOL_PROVIDERS[:] = [
tool for tool in _ALL_MCP_TOOL_PROVIDERS if tool not in excluded_tools
]

# Invalidate the cached callback map so it is rebuilt with the new config.
# No app yet (configured before `Dash()`) means there is no cache to clear.
try:
get_app().mcp_callback_map = None
except AppNotFoundError:
pass
6 changes: 2 additions & 4 deletions dash/mcp/_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,6 @@
def enable_mcp_server(app: Dash, mcp_path: str) -> None:
"""Add MCP routes to a Dash app."""

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

def _get_or_create_session_id() -> str:
"""
Creates a shared session ID shared across all clients. The session is
Expand Down Expand Up @@ -245,8 +242,9 @@ def _process_mcp_message(data: dict[str, Any]) -> dict[str, Any] | None:
request_id: str | int = _id if isinstance(_id, (str, int)) else ""

app = get_app()
if not hasattr(app, "mcp_callback_map"):
if app.mcp_callback_map is None:
app.mcp_callback_map = CallbackAdapterCollection(app)
app.mcp_decorated_functions = dict(MCP_DECORATED_FUNCTIONS)

mcp_methods = {
"initialize": _handle_initialize,
Expand Down
10 changes: 8 additions & 2 deletions dash/mcp/primitives/tools/callback_adapter_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from dash._utils import clean_property_name, split_callback_id
from dash._layout_utils import extract_text, find_component, traverse
from .callback_adapter import CallbackAdapter
from .tools_callbacks import CallbackTools


class CallbackAdapterCollection:
Expand All @@ -24,10 +25,15 @@ def __init__(self, app):

raw: list[tuple[str, dict]] = []
for output_id, cb_info in callback_map.items():
if cb_info.get("mcp_enabled") is False:
continue
if "callback" not in cb_info:
continue
if CallbackTools.callbacks_mcp_enabled_by_default:
if cb_info.get("mcp_enabled") is False:
# callbacks are included by default but this one has opted out
continue
elif not cb_info.get("mcp_enabled"):
# callbacks are excluded by default and this one has not opted in
continue
raw.append((output_id, cb_info))

self._tool_names_map = self._build_tool_names(raw)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@

from typing import TYPE_CHECKING

from dash import get_app

from ..tools_callbacks import CallbackTools
from .base import ToolDescriptionSource

if TYPE_CHECKING:
Expand Down Expand Up @@ -36,4 +35,4 @@ def _is_exposed(cls, callback: CallbackAdapter) -> bool:
per_callback = callback._cb_info.get("mcp_expose_docstring")
if per_callback is not None:
return per_callback
return get_app().config.get("mcp_expose_docstrings", False)
return CallbackTools.expose_docstrings_by_default
3 changes: 2 additions & 1 deletion dash/mcp/primitives/tools/tool_decorated_mcp_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from dash import get_app
from dash.mcp._decorator import MCPToolRegistration
from dash.mcp.primitives.tools.tools_callbacks import CallbackTools
from dash.mcp.primitives.tools.input_schemas import get_input_schema
from dash.mcp.primitives.tools.input_schemas.schema_callback_type_annotations import (
annotation_to_json_schema,
Expand Down Expand Up @@ -93,7 +94,7 @@ def _build_tool(tool_name: str, reg: MCPToolRegistration) -> Tool:

expose_docstring = reg["expose_docstring"]
if expose_docstring is None:
expose_docstring = get_app().config.get("mcp_expose_docstrings", False)
expose_docstring = CallbackTools.expose_docstrings_by_default

description = "MCP tool"
if expose_docstring:
Expand Down
4 changes: 4 additions & 0 deletions dash/mcp/primitives/tools/tools_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
class CallbackTools(MCPToolProvider):
"""Exposes every server-callable callback as an MCP tool."""

# Set by configure_mcp_server().
callbacks_mcp_enabled_by_default: bool = True
expose_docstrings_by_default: bool = False

@classmethod
def get_tool_names(cls) -> set[str]:
return get_app().mcp_callback_map.tool_names
Expand Down
26 changes: 26 additions & 0 deletions tests/integration/mcp/conftest.py
Comment thread
KoolADE85 marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@
collect_ignore_glob = []
if sys.version_info < (3, 10):
collect_ignore_glob.append("*")
else:
from dash.mcp.primitives.resources import ( # pylint: disable=wrong-import-position
_RESOURCE_PROVIDERS,
)
from dash.mcp.primitives.tools import ( # pylint: disable=wrong-import-position
_TOOL_PROVIDERS,
)
from dash.mcp.primitives.tools.tools_callbacks import ( # pylint: disable=wrong-import-position
CallbackTools,
)
from dash.mcp._decorator import ( # pylint: disable=wrong-import-position
MCP_DECORATED_FUNCTIONS,
)
from dash.mcp import _configure # pylint: disable=wrong-import-position


@pytest.fixture(autouse=True)
Expand All @@ -21,7 +35,19 @@ def _enable_mcp_for_integration_tests(monkeypatch):
@pytest.fixture(autouse=True)
def _reset_dash_app_state():
"""Reset Dash module-level state after each MCP test."""
initial_resources = list(_RESOURCE_PROVIDERS)
initial_tools = list(_TOOL_PROVIDERS)
initial_callbacks_default = CallbackTools.callbacks_mcp_enabled_by_default
initial_expose_docstrings = CallbackTools.expose_docstrings_by_default

yield

_RESOURCE_PROVIDERS[:] = initial_resources
_TOOL_PROVIDERS[:] = initial_tools
CallbackTools.callbacks_mcp_enabled_by_default = initial_callbacks_default
CallbackTools.expose_docstrings_by_default = initial_expose_docstrings
MCP_DECORATED_FUNCTIONS.clear()
_configure._current_config = dict(_configure._DEFAULT_CONFIG)
_get_app.APP = None
_get_app.app_context.set(None)

Expand Down
Loading
Loading