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
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 @@ -488,7 +488,6 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches
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 @@ -570,9 +569,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 @@ -616,6 +612,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

# 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",
]
90 changes: 90 additions & 0 deletions dash/mcp/_configure.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""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 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)

Check warning on line 23 in dash/mcp/_configure.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this redundant call.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AZ5lXRJ3GJg5i4XlgD0k&open=AZ5lXRJ3GJg5i4XlgD0k&pullRequest=3796
_ALL_MCP_TOOL_PROVIDERS = list(MCP_TOOL_PROVIDERS)

Check warning on line 24 in dash/mcp/_configure.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this redundant call.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AZ5lXRJ3GJg5i4XlgD0l&open=AZ5lXRJ3GJg5i4XlgD0l&pullRequest=3796


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

Any parameter that is omitted will be reset to its default value. Calling
with no args will reset all configuration to its default state.

: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:
...

CallbackTools.callbacks_mcp_enabled_by_default = include_callbacks
CallbackTools.expose_docstrings_by_default = expose_callback_docstrings

updated_resources = list(_ALL_MCP_RESOURCE_PROVIDERS)
if not include_layout:
updated_resources.remove(LayoutResource)
updated_resources.remove(ComponentsResource)
if not include_clientside_callbacks:
updated_resources.remove(ClientsideCallbacksResource)
if not include_pages:
updated_resources.remove(PagesResource)
updated_resources.remove(PageLayoutResource)
MCP_RESOURCE_PROVIDERS[:] = updated_resources

updated_tools = list(_ALL_MCP_TOOL_PROVIDERS)
if not include_layout:
updated_tools.remove(GetDashComponentTool)
MCP_TOOL_PROVIDERS[:] = updated_tools

get_app().mcp_callback_map = None
2 changes: 1 addition & 1 deletion dash/mcp/_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ 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)

mcp_methods = {
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
13 changes: 13 additions & 0 deletions tests/integration/mcp/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
import requests

from dash import _get_app
from dash.mcp.primitives.resources import _RESOURCE_PROVIDERS
from dash.mcp.primitives.tools import _TOOL_PROVIDERS
from dash.mcp.primitives.tools.tools_callbacks import CallbackTools

collect_ignore_glob = []
if sys.version_info < (3, 10):
Expand All @@ -21,7 +24,17 @@ 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
_get_app.APP = None
_get_app.app_context.set(None)

Expand Down
95 changes: 95 additions & 0 deletions tests/integration/mcp/test_mcp_configure.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Integration tests for configure_mcp()."""

from dash import Dash, Input, Output, dcc, html
from dash.mcp import configure_mcp_server, mcp_enabled

from tests.integration.mcp.conftest import _mcp_method, _mcp_tools


def test_mcpcfg001_disable_everything_decorated_function_still_appears(dash_duo):
"""configure_mcp with all content disabled: layout/callback/page resources and
tools are absent, but an @mcp_enabled decorated function still appears."""

@mcp_enabled
def my_tool(x: int) -> int:
return x * 2

app = Dash(__name__)
app.layout = html.Div([dcc.Input(id="inp"), html.Div(id="out")])

@app.callback(Output("out", "children"), Input("inp", "value"))
def update(val):
return val

configure_mcp_server(
include_layout=False,
include_callbacks=False,
include_clientside_callbacks=False,
include_pages=False,
)
dash_duo.start_server(app)

tools = _mcp_tools(dash_duo.server.url)
tool_names = [t["name"] for t in tools]
assert "update" not in tool_names
assert "get_dash_component" not in tool_names
assert tool_names == ["my_tool"]

resources = _mcp_method(dash_duo.server.url, "resources/list")
uris = [r["uri"] for r in resources["result"]["resources"]]
assert "dash://layout" not in uris
assert "dash://components" not in uris
assert "dash://clientside-callbacks" not in uris


def test_mcpcfg002_disable_layout_callbacks_still_appear(dash_duo):
"""configure_mcp(include_layout=False): callback tools are present,
get_dash_component is absent, layout resources are absent."""
app = Dash(__name__)
app.layout = html.Div([dcc.Input(id="inp"), html.Div(id="out")])

@app.callback(Output("out", "children"), Input("inp", "value"))
def update(val):
return val

configure_mcp_server(include_layout=False)
dash_duo.start_server(app)

tools = _mcp_tools(dash_duo.server.url)
tool_names = [t["name"] for t in tools]
assert "update" in tool_names
assert "get_dash_component" not in tool_names

resources = _mcp_method(dash_duo.server.url, "resources/list")
uris = [r["uri"] for r in resources["result"]["resources"]]
assert "dash://layout" not in uris
assert "dash://components" not in uris


def test_mcpcfg003_disable_callbacks_single_opt_in_layout_queryable(dash_duo):
"""configure_mcp(include_callbacks=False) with one explicit mcp_enabled=True
callback: only that callback appears as a tool, layout is queryable."""
app = Dash(__name__)
app.layout = html.Div(
[dcc.Input(id="inp"), html.Div(id="out"), html.Div(id="out2")]
)

@app.callback(Output("out", "children"), Input("inp", "value"))
def excluded(val):
return val

@app.callback(Output("out2", "children"), Input("inp", "value"), mcp_enabled=True)
def included(val):
return val

configure_mcp_server(include_callbacks=False)
dash_duo.start_server(app)

tools = _mcp_tools(dash_duo.server.url)
tool_names = [t["name"] for t in tools]
assert "included" in tool_names
assert "excluded" not in tool_names

resources = _mcp_method(dash_duo.server.url, "resources/list")
uris = [r["uri"] for r in resources["result"]["resources"]]
assert "dash://layout" in uris
Loading
Loading