Skip to content

Commit ece2abf

Browse files
committed
Add get_dash_component tool and callback tool dispatch pipeline
1 parent 0e54d74 commit ece2abf

6 files changed

Lines changed: 477 additions & 2 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""MCP tool listing and call handling.
2+
3+
Each tool module exports:
4+
- ``get_tool_names() -> set[str]``
5+
- ``get_tools() -> list[Tool]``
6+
- ``call_tool(tool_name, arguments) -> CallToolResult``
7+
8+
The __init__ assembles the list and dispatches calls by name.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from typing import Any
14+
15+
from mcp.types import CallToolResult, ListToolsResult
16+
17+
from dash.mcp.types import ToolNotFoundError
18+
19+
from . import tool_get_dash_component as _get_component
20+
from . import tools_callbacks as _callbacks
21+
22+
_TOOL_MODULES = [_callbacks, _get_component]
23+
24+
25+
def list_tools() -> ListToolsResult:
26+
"""Build the MCP tools/list response."""
27+
tools = []
28+
for mod in _TOOL_MODULES:
29+
tools.extend(mod.get_tools())
30+
return ListToolsResult(tools=tools)
31+
32+
33+
def call_tool(tool_name: str, arguments: dict[str, Any]) -> CallToolResult:
34+
"""Dispatch a tools/call request by tool name."""
35+
for mod in _TOOL_MODULES:
36+
if tool_name in mod.get_tool_names():
37+
result = mod.call_tool(tool_name, arguments)
38+
return result
39+
raise ToolNotFoundError(
40+
f"Tool not found: {tool_name}."
41+
" The app's callbacks may have changed."
42+
" Please call tools/list to refresh your tool list."
43+
)
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Built-in tool: get_dash_component."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from typing import Any
7+
8+
from mcp.types import CallToolResult, TextContent, Tool
9+
from pydantic import Field, TypeAdapter
10+
from typing_extensions import Annotated, NotRequired, TypedDict
11+
12+
from dash import get_app
13+
from dash._layout_utils import find_component
14+
from dash.mcp.types import ComponentPropertyInfo, ComponentQueryResult
15+
16+
17+
class _ComponentQueryInput(TypedDict):
18+
component_id: Annotated[str, Field(description="The component ID to query")]
19+
property: NotRequired[
20+
Annotated[
21+
str,
22+
Field(
23+
description="The property name to read (e.g. 'options', 'value'). Omit to list all defined properties."
24+
),
25+
]
26+
]
27+
28+
29+
_INPUT_SCHEMA = TypeAdapter(_ComponentQueryInput).json_schema()
30+
_OUTPUT_SCHEMA = TypeAdapter(ComponentQueryResult).json_schema()
31+
32+
NAME = "get_dash_component"
33+
34+
35+
def get_tool_names() -> set[str]:
36+
return {NAME}
37+
38+
39+
def get_tools() -> list[Tool]:
40+
return [_build_tool()]
41+
42+
43+
def _build_tool() -> Tool:
44+
return Tool(
45+
name=NAME,
46+
description=(
47+
"Get a component's properties, values, and tool relationships. "
48+
"If property is omitted, returns all defined properties. "
49+
"If property is specified, returns only that property. "
50+
"See the dash://components resource for available component IDs."
51+
),
52+
inputSchema=_INPUT_SCHEMA,
53+
outputSchema=_OUTPUT_SCHEMA,
54+
)
55+
56+
57+
def call_tool(tool_name: str, arguments: dict[str, Any]) -> CallToolResult:
58+
comp_id = arguments.get("component_id", "")
59+
if not comp_id:
60+
raise ValueError("component_id is required")
61+
62+
prop_filter = arguments.get("property", "")
63+
component = find_component(comp_id)
64+
65+
if component is None:
66+
callback_map = get_app().mcp_callback_map
67+
rendering_tools = [
68+
cb.tool_name
69+
for cb in callback_map
70+
if any(out["component_id"] == comp_id for out in cb.outputs)
71+
]
72+
msg = f"Component '{comp_id}' not found in static layout."
73+
if rendering_tools:
74+
msg += f" However, the following tools would modify it: {rendering_tools}."
75+
msg += " Use the dash://components resource to see statically available component IDs."
76+
return CallToolResult(
77+
content=[TextContent(type="text", text=msg)],
78+
isError=True,
79+
)
80+
81+
callback_map = get_app().mcp_callback_map
82+
83+
properties: dict[str, ComponentPropertyInfo] = {}
84+
for prop_name in getattr(component, "_prop_names", []):
85+
if prop_filter and prop_name != prop_filter:
86+
continue
87+
88+
value = callback_map.get_initial_value(f"{comp_id}.{prop_name}")
89+
if value is None:
90+
value = getattr(component, prop_name, None)
91+
if value is None:
92+
continue
93+
94+
modified_by: list[str] = []
95+
input_to: list[str] = []
96+
id_and_prop = f"{comp_id}.{prop_name}"
97+
for cb in callback_map:
98+
for out in cb.outputs:
99+
if out["id_and_prop"] == id_and_prop:
100+
modified_by.append(cb.tool_name)
101+
for inp in cb.inputs:
102+
if inp["id_and_prop"] == id_and_prop:
103+
input_to.append(cb.tool_name)
104+
105+
properties[prop_name] = ComponentPropertyInfo(
106+
initial_value=value,
107+
modified_by_tool=modified_by,
108+
input_to_tool=input_to,
109+
)
110+
111+
labels = callback_map.component_label_map.get(comp_id, [])
112+
113+
structured: ComponentQueryResult = ComponentQueryResult(
114+
component_id=comp_id,
115+
component_type=type(component).__name__,
116+
label=labels if labels else None,
117+
properties=properties,
118+
)
119+
120+
return CallToolResult(
121+
content=[TextContent(type="text", text=json.dumps(structured, default=str))],
122+
structuredContent=structured,
123+
)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Dynamic callback tools for MCP.
2+
3+
Handles listing, naming, and executing callback-based tools.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
from typing import Any
9+
10+
from mcp.types import CallToolResult, TextContent, Tool
11+
12+
from dash import get_app
13+
from dash.mcp.types import CallbackExecutionError, ToolNotFoundError
14+
15+
from .results import format_callback_response
16+
17+
18+
def get_tool_names() -> set[str]:
19+
return get_app().mcp_callback_map.tool_names
20+
21+
22+
def get_tools() -> list[Tool]:
23+
"""Return one Tool per server-callable callback."""
24+
return get_app().mcp_callback_map.as_mcp_tools()
25+
26+
27+
def call_tool(tool_name: str, arguments: dict[str, Any]) -> CallToolResult:
28+
"""Execute a callback tool by name."""
29+
from .callback_utils import run_callback
30+
31+
callback_map = get_app().mcp_callback_map
32+
cb = callback_map.find_by_tool_name(tool_name)
33+
if cb is None:
34+
raise ToolNotFoundError(
35+
f"Tool not found: {tool_name}."
36+
" The app's callbacks may have changed."
37+
" Please call tools/list to refresh your tool list."
38+
)
39+
40+
try:
41+
dispatch_response = run_callback(cb, arguments)
42+
except CallbackExecutionError as e:
43+
return CallToolResult(
44+
content=[TextContent(type="text", text=str(e))],
45+
isError=True,
46+
)
47+
return format_callback_response(dispatch_response, cb)

tests/unit/mcp/conftest.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
if sys.version_info < (3, 10):
1010
collect_ignore_glob.append("*")
1111
else:
12+
from dash.mcp.primitives.tools import call_tool, list_tools # pylint: disable=wrong-import-position
1213
from dash.mcp.primitives.tools.callback_adapter_collection import ( # pylint: disable=wrong-import-position
1314
CallbackAdapterCollection,
1415
)
@@ -42,10 +43,10 @@ def update_output(value):
4243

4344

4445
def _tools_list(app):
45-
"""Return tools as Tool objects via as_mcp_tools()."""
46+
"""Return all tools (callbacks + builtins) as Tool objects."""
4647
_setup_mcp(app)
4748
with app.server.test_request_context():
48-
return app.mcp_callback_map.as_mcp_tools()
49+
return list_tools().tools
4950

5051

5152
def _user_tool(tools):
@@ -81,3 +82,10 @@ def _desc_for(tool, param_name=None):
8182
if param_name is None:
8283
param_name = next(iter(props))
8384
return props[param_name].get("description", "")
85+
86+
87+
def _call_tool(app, tool_name, arguments=None):
88+
"""Call a tool via the dispatch pipeline and return the CallToolResult."""
89+
_setup_mcp(app)
90+
with app.server.test_request_context():
91+
return call_tool(tool_name, arguments or {})
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Tests for the get_dash_component built-in tool."""
2+
3+
from dash import Dash, Input, Output, dcc, html
4+
5+
from tests.unit.mcp.conftest import _call_tool, _make_app, _tools_list
6+
7+
8+
class TestGetDashComponent:
9+
def test_present_in_tools_list(self):
10+
app = _make_app()
11+
tool_names = [t.name for t in _tools_list(app)]
12+
assert "get_dash_component" in tool_names
13+
14+
def test_returns_structured_output_with_prop(self):
15+
app = Dash(__name__)
16+
app.layout = html.Div(
17+
[
18+
dcc.Dropdown(id="my-dd", options=["a", "b"], value="b"),
19+
]
20+
)
21+
22+
result = _call_tool(
23+
app,
24+
"get_dash_component",
25+
{
26+
"component_id": "my-dd",
27+
"property": "value",
28+
},
29+
)
30+
sc = result.structuredContent
31+
assert sc["component_id"] == "my-dd"
32+
assert sc["component_type"] == "Dropdown"
33+
assert "value" in sc["properties"]
34+
assert sc["properties"]["value"]["initial_value"] == "b"
35+
assert "options" not in sc["properties"]
36+
37+
def test_returns_all_props_without_property(self):
38+
app = Dash(__name__)
39+
app.layout = html.Div(
40+
[
41+
dcc.Dropdown(id="my-dd", options=["a", "b"], value="b"),
42+
]
43+
)
44+
45+
result = _call_tool(
46+
app,
47+
"get_dash_component",
48+
{
49+
"component_id": "my-dd",
50+
},
51+
)
52+
sc = result.structuredContent
53+
assert "options" in sc["properties"]
54+
assert "value" in sc["properties"]
55+
assert sc["properties"]["value"]["initial_value"] == "b"
56+
57+
def test_includes_label(self):
58+
app = Dash(__name__)
59+
app.layout = html.Div(
60+
[
61+
html.Label("Pick one", htmlFor="my-dd"),
62+
dcc.Dropdown(id="my-dd", options=["a", "b"], value="a"),
63+
]
64+
)
65+
66+
@app.callback(Output("my-dd", "value"), Input("my-dd", "options"))
67+
def noop(o):
68+
return "a"
69+
70+
result = _call_tool(
71+
app,
72+
"get_dash_component",
73+
{
74+
"component_id": "my-dd",
75+
},
76+
)
77+
sc = result.structuredContent
78+
assert sc["label"] == ["Pick one"]
79+
80+
def test_includes_tool_references(self):
81+
app = Dash(__name__)
82+
app.layout = html.Div(
83+
[
84+
dcc.Dropdown(id="dd", options=["a", "b"], value="a"),
85+
html.Div(id="out"),
86+
]
87+
)
88+
89+
@app.callback(Output("out", "children"), Input("dd", "value"))
90+
def update(val):
91+
return val
92+
93+
result = _call_tool(
94+
app,
95+
"get_dash_component",
96+
{
97+
"component_id": "dd",
98+
"property": "value",
99+
},
100+
)
101+
prop_info = result.structuredContent["properties"]["value"]
102+
assert "update" in prop_info["input_to_tool"]
103+
104+
def test_missing_id_returns_hint(self):
105+
app = _make_app()
106+
result = _call_tool(
107+
app,
108+
"get_dash_component",
109+
{
110+
"component_id": "nonexistent",
111+
"property": "value",
112+
},
113+
)
114+
text = result.content[0].text
115+
assert "nonexistent" in text
116+
assert "not found" in text
117+
assert "dash://components" in text

0 commit comments

Comments
 (0)