Skip to content

Commit 0a296ae

Browse files
authored
Merge pull request #3731 from plotly/feature/mcp-tools
MCP Server Part 4: Expose callbacks as tools
2 parents bd58bb9 + fea53f2 commit 0a296ae

29 files changed

Lines changed: 2150 additions & 247 deletions

.github/workflows/testing.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ jobs:
105105
echo "DISPLAY=:99" >> $GITHUB_ENV
106106
107107
- name: Run lint
108+
env:
109+
PYLINT_EXTRA_ARGS: ${{ matrix.python-version == '3.8' && '--ignored-modules=mcp' || '' }}
108110
run: npm run lint
109111

110112
- name: Run unit tests

.pylintrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -431,7 +431,7 @@ max-returns=6
431431
max-statements=50
432432

433433
# Minimum number of public methods for a class (see R0903).
434-
min-public-methods=2
434+
min-public-methods=1
435435

436436

437437
[IMPORTS]

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/callback_adapter.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,17 @@ def __init__(self, callback_output_id: str):
5050

5151
@cached_property
5252
def as_mcp_tool(self) -> Tool:
53-
"""Stub — will be implemented in a future PR."""
54-
raise NotImplementedError("as_mcp_tool will be implemented in a future PR.")
53+
"""Transforms the internal Dash callback to a structured MCP tool.
54+
55+
This tool can be serialized for LLM consumption or used internally for
56+
its computed data.
57+
"""
58+
return Tool(
59+
name=self.tool_name,
60+
description=self._description,
61+
inputSchema=self._input_schema,
62+
outputSchema=self._output_schema,
63+
)
5564

5665
def as_callback_body(self, kwargs: dict[str, Any]) -> CallbackExecutionBody:
5766
"""Transforms the given kwargs to a dict suitable for calling this callback.
@@ -126,7 +135,8 @@ def output_id(self) -> str:
126135

127136
@property
128137
def tool_name(self) -> str:
129-
return get_app().mcp_callback_map._tool_names_map[self._output_id] # pylint: disable=protected-access
138+
# pylint: disable-next=protected-access
139+
return get_app().mcp_callback_map._tool_names_map[self._output_id]
130140

131141
@cached_property
132142
def prevents_initial_call(self) -> bool:
@@ -141,7 +151,7 @@ def prevents_initial_call(self) -> bool:
141151

142152
@cached_property
143153
def _description(self) -> str:
144-
return build_tool_description(self.outputs, self._docstring)
154+
return build_tool_description(self)
145155

146156
@cached_property
147157
def _input_schema(self) -> dict[str, Any]:
@@ -376,7 +386,7 @@ def _expand_output_spec(
376386
output_id: str,
377387
cb_info: dict,
378388
resolved_inputs: list[CallbackInput],
379-
) -> list[CallbackOutputTarget]:
389+
) -> CallbackOutputTarget | list[CallbackOutputTarget]:
380390
"""Build the outputs spec, expanding wildcards to concrete IDs.
381391
382392
For wildcard outputs, derives concrete IDs from the resolved inputs.
@@ -408,6 +418,11 @@ def _expand_output_spec(
408418
else:
409419
results.append({"id": pid, "property": prop})
410420

421+
# Mirror the Dash renderer: single-output callbacks send a bare dict,
422+
# multi-output callbacks send a list. The framework's output value
423+
# matching depends on this shape.
424+
if len(results) == 1:
425+
return results[0]
411426
return results
412427

413428

dash/mcp/primitives/tools/callback_adapter_collection.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,7 @@ def get_initial_value(self, id_and_prop: str) -> Any:
114114
return getattr(layout_component, prop, None)
115115

116116
def as_mcp_tools(self) -> list[Tool]:
117-
"""Stub — will be implemented in a future PR."""
118-
raise NotImplementedError("as_mcp_tools will be implemented in a future PR.")
117+
return [cb.as_mcp_tool for cb in self._callbacks if cb.is_valid]
119118

120119
@property
121120
def tool_names(self) -> set[str]:
Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,33 @@
1-
"""Stub — real implementation in a later PR."""
1+
"""Tool-level description generation for MCP tools.
22
3+
Each source is a ``ToolDescriptionSource`` subclass that can add text
4+
to the tool's description. All sources are accumulated.
35
4-
def build_tool_description(outputs, docstring=None): # pylint: disable=unused-argument
5-
if docstring:
6-
return docstring.strip()
7-
return "Dash callback"
6+
This is distinct from per-parameter descriptions
7+
(in ``input_schemas/input_descriptions/``) which populate
8+
``inputSchema.properties.{param}.description``.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from typing import TYPE_CHECKING
14+
15+
from .base import ToolDescriptionSource
16+
from .description_docstring import DocstringDescription
17+
from .description_outputs import OutputSummaryDescription
18+
19+
if TYPE_CHECKING:
20+
from dash.mcp.primitives.tools.callback_adapter import CallbackAdapter
21+
22+
_SOURCES: list[type[ToolDescriptionSource]] = [
23+
OutputSummaryDescription,
24+
DocstringDescription,
25+
]
26+
27+
28+
def build_tool_description(callback: CallbackAdapter) -> str:
29+
"""Build a human-readable description for an MCP tool."""
30+
lines: list[str] = []
31+
for source in _SOURCES:
32+
lines.extend(source.describe(callback))
33+
return "\n".join(lines) if lines else "Dash callback"
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""Base class for tool-level description sources."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
if TYPE_CHECKING:
8+
from dash.mcp.primitives.tools.callback_adapter import CallbackAdapter
9+
10+
11+
class ToolDescriptionSource:
12+
"""A source of text that can describe an MCP tool.
13+
14+
Subclasses implement ``describe`` to return strings that will be
15+
joined into the tool's ``description`` field. All sources are
16+
accumulated — every source can add text to the overall description.
17+
"""
18+
19+
@classmethod
20+
def describe(cls, callback: CallbackAdapter) -> list[str]:
21+
raise NotImplementedError
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Callback docstring for tool descriptions."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
from dash import get_app
8+
9+
from .base import ToolDescriptionSource
10+
11+
if TYPE_CHECKING:
12+
from dash.mcp.primitives.tools.callback_adapter import CallbackAdapter
13+
14+
15+
class DocstringDescription(ToolDescriptionSource):
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+
"""
23+
24+
@classmethod
25+
def describe(cls, callback: CallbackAdapter) -> list[str]:
26+
if not cls._is_exposed(callback):
27+
return []
28+
docstring = callback._docstring # pylint: disable=protected-access
29+
if docstring:
30+
return ["", docstring.strip()]
31+
return []
32+
33+
@classmethod
34+
def _is_exposed(cls, callback: CallbackAdapter) -> bool:
35+
# pylint: disable-next=protected-access
36+
per_callback = callback._cb_info.get("mcp_expose_docstring")
37+
if per_callback is not None:
38+
return per_callback
39+
return get_app().config.get("mcp_expose_docstrings", False)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Output summary for tool descriptions."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
from ..prop_roles import iter_prop_roles
8+
from .base import ToolDescriptionSource
9+
10+
if TYPE_CHECKING:
11+
from dash.mcp.primitives.tools.callback_adapter import CallbackAdapter
12+
13+
14+
def _describe_output(comp_type: str | None, prop: str) -> str | None:
15+
for role in iter_prop_roles():
16+
if role.description is not None and role.matches(comp_type, prop):
17+
return role.description
18+
return None
19+
20+
21+
class OutputSummaryDescription(ToolDescriptionSource):
22+
"""Produce a short summary of what the callback outputs represent."""
23+
24+
@classmethod
25+
def describe(cls, callback: CallbackAdapter) -> list[str]:
26+
outputs = callback.outputs
27+
if not outputs:
28+
return ["Dash callback"]
29+
30+
lines: list[str] = []
31+
for out in outputs:
32+
comp_id = out["component_id"]
33+
prop = out["property"]
34+
description = _describe_output(out.get("component_type"), prop)
35+
36+
if description is not None:
37+
lines.append(f"- {comp_id}.{prop}: {description}")
38+
else:
39+
lines.append(f"- {comp_id}.{prop}")
40+
41+
n = len(outputs)
42+
if n == 1:
43+
return [lines[0].lstrip("- ")]
44+
header = f"Returns {n} output{'s' if n > 1 else ''}:"
45+
return [header] + lines
Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,44 @@
1-
"""Stub — real implementation in a later PR."""
1+
"""Input schema generation for MCP tool inputSchema fields.
22
3+
Each source is an ``InputSchemaSource`` subclass that can type
4+
an input parameter. Sources are tried in priority order — first
5+
non-None wins.
6+
"""
37

4-
def get_input_schema(param): # pylint: disable=unused-argument
5-
return {}
8+
from __future__ import annotations
9+
10+
from typing import Any
11+
12+
from dash.mcp.types import MCPInput
13+
14+
from .base import InputSchemaSource
15+
from .schema_callback_type_annotations import AnnotationSchema
16+
from .schema_component_proptypes_overrides import OverrideSchema
17+
from .schema_component_proptypes import ComponentPropSchema
18+
from .input_descriptions import get_property_description
19+
20+
_SOURCES: list[type[InputSchemaSource]] = [
21+
AnnotationSchema,
22+
OverrideSchema,
23+
ComponentPropSchema,
24+
]
25+
26+
27+
def get_input_schema(param: MCPInput) -> dict[str, Any]:
28+
"""Return the complete JSON Schema for a callback input parameter.
29+
30+
Type sources provide ``type``/``enum`` (first non-None wins).
31+
Description is assembled by ``input_descriptions``.
32+
"""
33+
schema: dict[str, Any] = {}
34+
for source in _SOURCES:
35+
result = source.get_schema(param)
36+
if result is not None:
37+
schema = result
38+
break
39+
40+
description = get_property_description(param)
41+
if description:
42+
schema = {**schema, "description": description}
43+
44+
return schema

0 commit comments

Comments
 (0)