Skip to content

Commit c87cb04

Browse files
committed
Disable docstrings by default in MCP tool descriptions
1 parent 2b84e63 commit c87cb04

4 files changed

Lines changed: 70 additions & 6 deletions

File tree

dash/_callback.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ def callback(
8282
optional: Optional[bool] = False,
8383
hidden: Optional[bool] = None,
8484
mcp_enabled: bool = True,
85+
mcp_expose_docstring: Optional[bool] = None,
8586
**_kwargs,
8687
) -> Callable[..., Any]:
8788
"""
@@ -234,6 +235,7 @@ def callback(
234235
optional=optional,
235236
hidden=hidden,
236237
mcp_enabled=mcp_enabled,
238+
mcp_expose_docstring=mcp_expose_docstring,
237239
)
238240

239241

@@ -282,6 +284,7 @@ def insert_callback(
282284
optional=False,
283285
hidden=None,
284286
mcp_enabled=True,
287+
mcp_expose_docstring=None,
285288
):
286289
if prevent_initial_call is None:
287290
prevent_initial_call = config_prevent_initial_callbacks
@@ -323,6 +326,7 @@ def insert_callback(
323326
"allow_dynamic_callbacks": dynamic_creator,
324327
"no_output": no_output,
325328
"mcp_enabled": mcp_enabled,
329+
"mcp_expose_docstring": mcp_expose_docstring,
326330
}
327331
callback_list.append(callback_spec)
328332

@@ -658,6 +662,7 @@ def register_callback(
658662
optional=_kwargs.get("optional", False),
659663
hidden=_kwargs.get("hidden", None),
660664
mcp_enabled=_kwargs.get("mcp_enabled", True),
665+
mcp_expose_docstring=_kwargs.get("mcp_expose_docstring"),
661666
)
662667

663668
# pylint: disable=too-many-locals

dash/mcp/primitives/tools/descriptions/description_docstring.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,35 @@
44

55
from typing import TYPE_CHECKING
66

7+
from dash import get_app
8+
79
from .base import ToolDescriptionSource
810

911
if TYPE_CHECKING:
1012
from dash.mcp.primitives.tools.callback_adapter import CallbackAdapter
1113

1214

1315
class DocstringDescription(ToolDescriptionSource):
14-
"""Return the callback's docstring as description lines."""
16+
"""Return the callback's docstring as description lines.
17+
18+
Gated behind an opt-in flag: docstrings may contain sensitive
19+
implementation details that the browser never surfaces to users,
20+
so we don't expose them to MCP clients unless the author opts in
21+
— either per-callback or app-wide.
22+
"""
1523

1624
@classmethod
1725
def describe(cls, callback: CallbackAdapter) -> list[str]:
26+
if not cls._is_exposed(callback):
27+
return []
1828
docstring = callback._docstring
1929
if docstring:
2030
return ["", docstring.strip()]
2131
return []
32+
33+
@classmethod
34+
def _is_exposed(cls, callback: CallbackAdapter) -> bool:
35+
per_callback = callback._cb_info.get("mcp_expose_docstring")
36+
if per_callback is not None:
37+
return per_callback
38+
return get_app().config.get("mcp_expose_docstrings", False)

tests/unit/mcp/conftest.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@ def _make_app(**kwargs):
3333
]
3434
)
3535

36-
@app.callback(Output("my-output", "children"), Input("my-input", "children"))
36+
@app.callback(
37+
Output("my-output", "children"),
38+
Input("my-input", "children"),
39+
mcp_expose_docstring=True,
40+
)
3741
def update_output(value):
3842
"""Test callback docstring."""
3943
return f"echo: {value}"

tests/unit/mcp/tools/test_callback_adapter.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -201,10 +201,48 @@ def test_returns_tool_instance(self, simple_app):
201201
assert isinstance(tool, Tool)
202202
assert tool.name == "update"
203203

204-
def test_description_includes_docstring(self, simple_app):
205-
with simple_app.server.test_request_context():
206-
tool = app_context.get().mcp_callback_map[0].as_mcp_tool
207-
assert "Update output." in tool.description
204+
def test_docstring_hidden_by_default(self):
205+
"""Callback docstrings are not exposed to MCP by default."""
206+
app = Dash(__name__)
207+
app.layout = html.Div([dcc.Input(id="inp"), html.Div(id="out")])
208+
209+
@app.callback(Output("out", "children"), Input("inp", "value"))
210+
def update(val):
211+
"""sensitive callback docstring text that must not leak to LLMs"""
212+
return val
213+
214+
app_context.set(app)
215+
app.mcp_callback_map = CallbackAdapterCollection(app)
216+
217+
with app.server.test_request_context():
218+
tool = app.mcp_callback_map[0].as_mcp_tool
219+
assert (
220+
"sensitive callback docstring text that must not leak to LLMs"
221+
not in tool.description
222+
)
223+
224+
def test_docstring_exposed_when_opted_in_per_callback(self):
225+
app = Dash(__name__)
226+
app.layout = html.Div([dcc.Input(id="inp"), html.Div(id="out")])
227+
228+
@app.callback(
229+
Output("out", "children"),
230+
Input("inp", "value"),
231+
mcp_expose_docstring=True,
232+
)
233+
def update(val):
234+
"""intentionally-exposed callback docstring text for the LLM"""
235+
return val
236+
237+
app_context.set(app)
238+
app.mcp_callback_map = CallbackAdapterCollection(app)
239+
240+
with app.server.test_request_context():
241+
tool = app.mcp_callback_map[0].as_mcp_tool
242+
assert (
243+
"intentionally-exposed callback docstring text for the LLM"
244+
in tool.description
245+
)
208246

209247
def test_description_includes_output_target(self, simple_app):
210248
with simple_app.server.test_request_context():

0 commit comments

Comments
 (0)