From d04009cfb576ffeb7f254924baf6bceaeaf28fb0 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Tue, 26 May 2026 11:30:33 -0600 Subject: [PATCH 1/4] Implement configuration function to customize MCP server --- dash/_callback.py | 6 +- dash/_configs.py | 1 - dash/dash.py | 5 +- dash/mcp/__init__.py | 2 + dash/mcp/_configure.py | 90 +++++++ dash/mcp/_server.py | 2 +- .../tools/callback_adapter_collection.py | 10 +- .../descriptions/description_docstring.py | 5 +- .../tools/tool_decorated_mcp_functions.py | 3 +- dash/mcp/primitives/tools/tools_callbacks.py | 4 + tests/integration/mcp/conftest.py | 13 + tests/integration/mcp/test_mcp_configure.py | 95 ++++++++ tests/unit/mcp/test_mcp_configure.py | 222 ++++++++++++++++++ tests/unit/mcp/tools/test_mcp_tools.py | 45 ---- 14 files changed, 443 insertions(+), 60 deletions(-) create mode 100644 dash/mcp/_configure.py create mode 100644 tests/integration/mcp/test_mcp_configure.py create mode 100644 tests/unit/mcp/test_mcp_configure.py diff --git a/dash/_callback.py b/dash/_callback.py index 037f3d189b..264510d6eb 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -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]]: @@ -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: @@ -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"), ) diff --git a/dash/_configs.py b/dash/_configs.py index 25a401523b..0e1ab75505 100644 --- a/dash/_configs.py +++ b/dash/_configs.py @@ -35,7 +35,6 @@ def load_dash_env_vars(): "DASH_COMPRESS", "DASH_MCP_ENABLED", "DASH_MCP_PATH", - "DASH_MCP_EXPOSE_DOCSTRINGS", "HOST", "PORT", ) diff --git a/dash/dash.py b/dash/dash.py index d3f0a67c71..6f60482c3a 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -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, ): @@ -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( [ @@ -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 # list of inline scripts self._inline_scripts: list = [] diff --git a/dash/mcp/__init__.py b/dash/mcp/__init__.py index 2e6edffdb2..b22c049bf6 100644 --- a/dash/mcp/__init__.py +++ b/dash/mcp/__init__.py @@ -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", ] diff --git a/dash/mcp/_configure.py b/dash/mcp/_configure.py new file mode 100644 index 0000000000..96d349bed7 --- /dev/null +++ b/dash/mcp/_configure.py @@ -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) +_ALL_MCP_TOOL_PROVIDERS = list(MCP_TOOL_PROVIDERS) + + +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 diff --git a/dash/mcp/_server.py b/dash/mcp/_server.py index 07b0520bb9..35c57a891e 100644 --- a/dash/mcp/_server.py +++ b/dash/mcp/_server.py @@ -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 = { diff --git a/dash/mcp/primitives/tools/callback_adapter_collection.py b/dash/mcp/primitives/tools/callback_adapter_collection.py index 4fdaeabe9c..aab0e576d5 100644 --- a/dash/mcp/primitives/tools/callback_adapter_collection.py +++ b/dash/mcp/primitives/tools/callback_adapter_collection.py @@ -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: @@ -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) diff --git a/dash/mcp/primitives/tools/descriptions/description_docstring.py b/dash/mcp/primitives/tools/descriptions/description_docstring.py index c34d527077..b22b711ec2 100644 --- a/dash/mcp/primitives/tools/descriptions/description_docstring.py +++ b/dash/mcp/primitives/tools/descriptions/description_docstring.py @@ -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: @@ -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 diff --git a/dash/mcp/primitives/tools/tool_decorated_mcp_functions.py b/dash/mcp/primitives/tools/tool_decorated_mcp_functions.py index 0b3edbbcbe..d3b7738de4 100644 --- a/dash/mcp/primitives/tools/tool_decorated_mcp_functions.py +++ b/dash/mcp/primitives/tools/tool_decorated_mcp_functions.py @@ -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, @@ -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: diff --git a/dash/mcp/primitives/tools/tools_callbacks.py b/dash/mcp/primitives/tools/tools_callbacks.py index 2a2d866ea9..4e007acf19 100644 --- a/dash/mcp/primitives/tools/tools_callbacks.py +++ b/dash/mcp/primitives/tools/tools_callbacks.py @@ -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 diff --git a/tests/integration/mcp/conftest.py b/tests/integration/mcp/conftest.py index aad3f1b1b5..a50004bd24 100644 --- a/tests/integration/mcp/conftest.py +++ b/tests/integration/mcp/conftest.py @@ -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): @@ -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) diff --git a/tests/integration/mcp/test_mcp_configure.py b/tests/integration/mcp/test_mcp_configure.py new file mode 100644 index 0000000000..0ea952ac17 --- /dev/null +++ b/tests/integration/mcp/test_mcp_configure.py @@ -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 diff --git a/tests/unit/mcp/test_mcp_configure.py b/tests/unit/mcp/test_mcp_configure.py new file mode 100644 index 0000000000..2d6f787833 --- /dev/null +++ b/tests/unit/mcp/test_mcp_configure.py @@ -0,0 +1,222 @@ +"""Tests for the public configure_mcp_server() API. + +Covers: +- include_layout / include_clientside_callbacks / include_pages toggling resources +- include_layout toggling GetDashComponentTool +- include_callbacks toggling CallbackAdapterCollection filter mode + (opt-out when True, opt-in when False) +- expose_callback_docstrings +- idempotency: re-calling configure_mcp restores providers +- cache invalidation: app.mcp_callback_map is cleared on configure_mcp +""" + +import pytest + +import dash._get_app as _get_app_module +from dash import Dash, Input, Output, dcc, html +from dash._get_app import app_context +from dash.mcp import configure_mcp_server +from dash.mcp.primitives.resources import _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 +from dash.mcp.primitives.tools.callback_adapter_collection import ( + CallbackAdapterCollection, +) + +from dash.mcp.primitives.tools.tool_get_dash_component import GetDashComponentTool +from dash.mcp.primitives.tools.tools_callbacks import CallbackTools + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +_DEFAULT_RESOURCE_PROVIDERS = list(_RESOURCE_PROVIDERS) +_DEFAULT_TOOL_PROVIDERS = list(_TOOL_PROVIDERS) + + +@pytest.fixture(autouse=True) +def _reset_mcp_module_state(): + """Restore module-level MCP state after every test.""" + yield + + CallbackTools.callbacks_mcp_enabled_by_default = True + CallbackTools.expose_docstrings_by_default = False + _RESOURCE_PROVIDERS[:] = list(_DEFAULT_RESOURCE_PROVIDERS) + _TOOL_PROVIDERS[:] = list(_DEFAULT_TOOL_PROVIDERS) + _get_app_module.APP = None + _get_app_module.app_context.set(None) + + +def _make_app(**cb_kwargs): + """Minimal app with one callback. cb_kwargs forwarded to @app.callback.""" + app = Dash(__name__) + app.layout = html.Div([dcc.Input(id="inp"), html.Div(id="out")]) + + @app.callback(Output("out", "children"), Input("inp", "value"), **cb_kwargs) + def update(val): + """A callback docstring.""" + return val + + return app + + +def _collection(app): + app_context.set(app) + return CallbackAdapterCollection(app) + + +# --------------------------------------------------------------------------- +# Resources — include_layout +# --------------------------------------------------------------------------- + + +def test_mcpc001_include_layout_toggles_resources_and_tool(): + """include_layout=False removes LayoutResource, ComponentsResource, and + GetDashComponentTool; re-enabling restores all three. ClientsideCallbacks + and pages providers are independent.""" + _make_app() + + configure_mcp_server(include_layout=False) + + assert LayoutResource not in _RESOURCE_PROVIDERS + assert ComponentsResource not in _RESOURCE_PROVIDERS + assert GetDashComponentTool not in _TOOL_PROVIDERS + # Independent knobs are unaffected + assert ClientsideCallbacksResource in _RESOURCE_PROVIDERS + assert PagesResource in _RESOURCE_PROVIDERS + + configure_mcp_server(include_layout=True) + + assert LayoutResource in _RESOURCE_PROVIDERS + assert ComponentsResource in _RESOURCE_PROVIDERS + assert GetDashComponentTool in _TOOL_PROVIDERS + + +# --------------------------------------------------------------------------- +# Resources — include_clientside_callbacks +# --------------------------------------------------------------------------- + + +def test_mcpc002_include_clientside_callbacks_is_independent_knob(): + """include_clientside_callbacks=False removes only ClientsideCallbacksResource; + layout resources are unaffected. Restoring brings it back.""" + _make_app() + + configure_mcp_server(include_clientside_callbacks=False) + + assert ClientsideCallbacksResource not in _RESOURCE_PROVIDERS + assert LayoutResource in _RESOURCE_PROVIDERS + assert ComponentsResource in _RESOURCE_PROVIDERS + + configure_mcp_server(include_clientside_callbacks=True) + + assert ClientsideCallbacksResource in _RESOURCE_PROVIDERS + + +# --------------------------------------------------------------------------- +# Resources — include_pages +# --------------------------------------------------------------------------- + + +def test_mcpc003_include_pages_is_independent_knob(): + """include_pages=False removes PagesResource and PageLayoutResource; + layout is unaffected.""" + _make_app() + + configure_mcp_server(include_pages=False) + + assert PagesResource not in _RESOURCE_PROVIDERS + assert PageLayoutResource not in _RESOURCE_PROVIDERS + assert LayoutResource in _RESOURCE_PROVIDERS + + +# --------------------------------------------------------------------------- +# Tools — include_callbacks filter mode +# --------------------------------------------------------------------------- + + +def test_mcpc004_include_callbacks_true_opt_out_mode(): + """include_callbacks=True (default): mcp_enabled=None includes; + mcp_enabled=False excludes.""" + app_none = _make_app() # mcp_enabled defaults to None + configure_mcp_server(include_callbacks=True) + assert len(_collection(app_none)) == 1 + + app_false = _make_app(mcp_enabled=False) + configure_mcp_server(include_callbacks=True) + assert len(_collection(app_false)) == 0 + + +def test_mcpc005_include_callbacks_false_opt_in_mode(): + """include_callbacks=False: mcp_enabled=True opts in; + mcp_enabled=None or False both exclude (redundant False is valid).""" + app_true = _make_app(mcp_enabled=True) + configure_mcp_server(include_callbacks=False) + assert len(_collection(app_true)) == 1 + + app_none = _make_app() # mcp_enabled=None + configure_mcp_server(include_callbacks=False) + assert len(_collection(app_none)) == 0 + + app_false = _make_app(mcp_enabled=False) + configure_mcp_server(include_callbacks=False) + assert len(_collection(app_false)) == 0 + + +# --------------------------------------------------------------------------- +# expose_callback_docstrings +# --------------------------------------------------------------------------- + + +def test_mcpc006_expose_callback_docstrings(): + """expose_callback_docstrings=True exposes docstrings; False hides them.""" + app = _make_app() + configure_mcp_server(expose_callback_docstrings=True) + app_context.set(app) + app.mcp_callback_map = CallbackAdapterCollection(app) + with app.server.test_request_context(): + tool = app.mcp_callback_map[0].as_mcp_tool + assert "A callback docstring." in tool.description + + app2 = _make_app() + configure_mcp_server(expose_callback_docstrings=False) + app_context.set(app2) + app2.mcp_callback_map = CallbackAdapterCollection(app2) + with app2.server.test_request_context(): + tool2 = app2.mcp_callback_map[0].as_mcp_tool + assert "A callback docstring." not in tool2.description + + +def test_mcpc008_per_callback_false_overrides_server_level_docstrings(): + """Per-callback mcp_expose_docstring=False wins over configure_mcp_server opt-in.""" + app = _make_app(mcp_expose_docstring=False) + configure_mcp_server(expose_callback_docstrings=True) + app_context.set(app) + app.mcp_callback_map = CallbackAdapterCollection(app) + with app.server.test_request_context(): + tool = app.mcp_callback_map[0].as_mcp_tool + assert "A callback docstring." not in tool.description + + +# --------------------------------------------------------------------------- +# Cache invalidation +# --------------------------------------------------------------------------- + + +def test_mcpc007_configure_mcp_invalidates_mcp_callback_map(): + """configure_mcp clears app.mcp_callback_map so it is rebuilt with new config.""" + app = _make_app() + app_context.set(app) + app.mcp_callback_map = CallbackAdapterCollection(app) + assert len(app.mcp_callback_map) == 1 + + configure_mcp_server(include_callbacks=False) + + assert app.mcp_callback_map is None diff --git a/tests/unit/mcp/tools/test_mcp_tools.py b/tests/unit/mcp/tools/test_mcp_tools.py index 3255809982..cacaf13b14 100644 --- a/tests/unit/mcp/tools/test_mcp_tools.py +++ b/tests/unit/mcp/tools/test_mcp_tools.py @@ -309,51 +309,6 @@ def test_mcpt014_typed_annotation_narrows_schema(typed_app): assert tool.inputSchema["properties"]["val"]["type"] == "string" -def test_mcpt016_app_level_opt_in_exposes_docstrings(): - """Dash(mcp_expose_docstrings=True) exposes docstrings for all callbacks.""" - app = Dash(__name__, mcp_expose_docstrings=True) - app.layout = html.Div([dcc.Input(id="inp"), html.Div(id="out")]) - - @app.callback(Output("out", "children"), Input("inp", "value")) - def update(val): - """intentionally-exposed callback docstring text for the LLM""" - return val - - app_context.set(app) - app.mcp_callback_map = CallbackAdapterCollection(app) - - with app.server.test_request_context(): - tool = app.mcp_callback_map[0].as_mcp_tool - assert ( - "intentionally-exposed callback docstring text for the LLM" in tool.description - ) - - -def test_mcpt017_per_callback_false_overrides_app_level_opt_in(): - """Per-callback mcp_expose_docstring=False wins over app-level opt-in.""" - app = Dash(__name__, mcp_expose_docstrings=True) - app.layout = html.Div([dcc.Input(id="inp"), html.Div(id="out")]) - - @app.callback( - Output("out", "children"), - Input("inp", "value"), - mcp_expose_docstring=False, - ) - def update(val): - """sensitive callback docstring text that must not leak to LLMs""" - return val - - app_context.set(app) - app.mcp_callback_map = CallbackAdapterCollection(app) - - with app.server.test_request_context(): - tool = app.mcp_callback_map[0].as_mcp_tool - assert ( - "sensitive callback docstring text that must not leak to LLMs" - not in tool.description - ) - - # --------------------------------------------------------------------------- # Tests — end-to-end Tool shape # --------------------------------------------------------------------------- From 17f67ec59fe8531c04f012a37917b50583d177c8 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Mon, 1 Jun 2026 14:51:41 -0600 Subject: [PATCH 2/4] Fix CI test --- tests/integration/mcp/conftest.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/integration/mcp/conftest.py b/tests/integration/mcp/conftest.py index a50004bd24..60a22f407e 100644 --- a/tests/integration/mcp/conftest.py +++ b/tests/integration/mcp/conftest.py @@ -6,13 +6,20 @@ 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): 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, + ) @pytest.fixture(autouse=True) From 52b285885af9ddeff97bf7578d8025c33b136dff Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Mon, 1 Jun 2026 14:52:13 -0600 Subject: [PATCH 3/4] Fix @mcp_enabled decorator not working if defined after the app constructor --- dash/mcp/_server.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/dash/mcp/_server.py b/dash/mcp/_server.py index 35c57a891e..93edf7853f 100644 --- a/dash/mcp/_server.py +++ b/dash/mcp/_server.py @@ -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 @@ -247,6 +244,7 @@ def _process_mcp_message(data: dict[str, Any]) -> dict[str, Any] | None: app = get_app() 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, From c5c48292a797e2a656fc168e4d5be012560df92a Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Wed, 3 Jun 2026 11:49:42 -0600 Subject: [PATCH 4/4] Address PR feedback --- CHANGELOG.md | 8 ++ dash/mcp/_configure.py | 101 ++++++++++++++------ tests/integration/mcp/conftest.py | 6 ++ tests/integration/mcp/test_mcp_configure.py | 27 ++++++ tests/unit/mcp/test_mcp_configure.py | 60 ++++++++++++ 5 files changed, 173 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b760cbc8f5..d7c04cd88b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/dash/mcp/_configure.py b/dash/mcp/_configure.py index 96d349bed7..2844483a64 100644 --- a/dash/mcp/_configure.py +++ b/dash/mcp/_configure.py @@ -6,6 +6,8 @@ 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 @@ -23,20 +25,37 @@ _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: bool = True, - include_callbacks: bool = True, - include_clientside_callbacks: bool = True, - include_pages: bool = True, - expose_callback_docstrings: bool = False, + 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. - Any parameter that is omitted will be reset to its default value. Calling - with no args will reset all configuration to its default state. + 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``. @@ -66,25 +85,49 @@ def configure_mcp_server( 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 + 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 diff --git a/tests/integration/mcp/conftest.py b/tests/integration/mcp/conftest.py index 60a22f407e..d0ef0dda1f 100644 --- a/tests/integration/mcp/conftest.py +++ b/tests/integration/mcp/conftest.py @@ -20,6 +20,10 @@ 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) @@ -42,6 +46,8 @@ def _reset_dash_app_state(): _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) diff --git a/tests/integration/mcp/test_mcp_configure.py b/tests/integration/mcp/test_mcp_configure.py index 0ea952ac17..1b9abee7b2 100644 --- a/tests/integration/mcp/test_mcp_configure.py +++ b/tests/integration/mcp/test_mcp_configure.py @@ -93,3 +93,30 @@ def included(val): resources = _mcp_method(dash_duo.server.url, "resources/list") uris = [r["uri"] for r in resources["result"]["resources"]] assert "dash://layout" in uris + + +def test_mcpcfg004_configure_before_app_governs_live_server(dash_duo): + """configure_mcp_server() called BEFORE Dash() still governs the running + server: app construction and the first request do not reset the config.""" + # Configure before the app exists. + configure_mcp_server(include_layout=False, include_callbacks=False) + + app = Dash(__name__) + app.layout = html.Div([dcc.Input(id="inp"), html.Div(id="out")]) + + @app.callback(Output("out", "children"), Input("inp", "value"), mcp_enabled=True) + def included(val): + return val + + dash_duo.start_server(app) + + # First real request builds the callback map; pre-app config must hold. + tools = _mcp_tools(dash_duo.server.url) + tool_names = [t["name"] for t in tools] + assert "included" in tool_names # opt-in honored under include_callbacks=False + assert "get_dash_component" not in tool_names # include_layout=False honored + + 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 diff --git a/tests/unit/mcp/test_mcp_configure.py b/tests/unit/mcp/test_mcp_configure.py index 2d6f787833..6987b81111 100644 --- a/tests/unit/mcp/test_mcp_configure.py +++ b/tests/unit/mcp/test_mcp_configure.py @@ -46,10 +46,13 @@ def _reset_mcp_module_state(): """Restore module-level MCP state after every test.""" yield + from dash.mcp import _configure + CallbackTools.callbacks_mcp_enabled_by_default = True CallbackTools.expose_docstrings_by_default = False _RESOURCE_PROVIDERS[:] = list(_DEFAULT_RESOURCE_PROVIDERS) _TOOL_PROVIDERS[:] = list(_DEFAULT_TOOL_PROVIDERS) + _configure._current_config = dict(_configure._DEFAULT_CONFIG) _get_app_module.APP = None _get_app_module.app_context.set(None) @@ -220,3 +223,60 @@ def test_mcpc007_configure_mcp_invalidates_mcp_callback_map(): configure_mcp_server(include_callbacks=False) assert app.mcp_callback_map is None + + +# --------------------------------------------------------------------------- +# Patch-style semantics: omitted params retain their previous value +# --------------------------------------------------------------------------- + + +def test_mcpc009_omitted_params_retain_previous_value(): + """A subsequent call only updates the params it is passed; previously-set + values persist instead of resetting to their defaults.""" + _make_app() + + configure_mcp_server(include_layout=False) + assert LayoutResource not in _RESOURCE_PROVIDERS + assert GetDashComponentTool not in _TOOL_PROVIDERS + + # Touch an unrelated knob; include_layout must stay disabled. + configure_mcp_server(include_pages=False) + + assert LayoutResource not in _RESOURCE_PROVIDERS + assert GetDashComponentTool not in _TOOL_PROVIDERS + assert PagesResource not in _RESOURCE_PROVIDERS + + +# --------------------------------------------------------------------------- +# Configuring before the app survives construction and first-request build +# --------------------------------------------------------------------------- + + +def test_mcpc010_configure_before_app_survives_construction(): + """Config applied before `Dash()` exists is not reset by `Dash.__init__`: + provider lists set pre-app remain in effect after construction.""" + # Configure first, with no app in context (must not raise). + configure_mcp_server(include_layout=False) + + # Constructing the app must not restore the disabled providers. + _make_app() + + assert LayoutResource not in _RESOURCE_PROVIDERS + assert ComponentsResource not in _RESOURCE_PROVIDERS + assert GetDashComponentTool not in _TOOL_PROVIDERS + + +def test_mcpc011_configure_before_app_governs_callback_filter_mode(): + """`include_callbacks=False` set before `Dash()` still governs how the + callback map is built on first request (opt-in mode is honored).""" + # Configure opt-in mode before any app exists. + configure_mcp_server(include_callbacks=False) + + # App built afterward; its single callback does not opt in. + app_none = _make_app() # mcp_enabled defaults to None + # The collection is built lazily (as on first request) and must reflect + # the pre-app opt-in config: the non-opted-in callback is excluded. + assert len(_collection(app_none)) == 0 + + app_opted_in = _make_app(mcp_enabled=True) + assert len(_collection(app_opted_in)) == 1