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 37eb7a1ffb..369e207bb9 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -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, ): @@ -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( [ @@ -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 = [] 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 # ---------------------------------------------------------------------------