Skip to content

Commit 4ea79de

Browse files
committed
Refactor tools to use a base class (just as resources do)
1 parent ece2abf commit 4ea79de

4 files changed

Lines changed: 165 additions & 133 deletions

File tree

dash/mcp/primitives/tools/__init__.py

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,4 @@
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-
"""
1+
"""MCP tool listing and call handling."""
102

113
from __future__ import annotations
124

@@ -16,26 +8,29 @@
168

179
from dash.mcp.types import ToolNotFoundError
1810

19-
from . import tool_get_dash_component as _get_component
20-
from . import tools_callbacks as _callbacks
11+
from .base import MCPToolProvider
12+
from .tool_get_dash_component import GetDashComponentTool
13+
from .tools_callbacks import CallbackTools
2114

22-
_TOOL_MODULES = [_callbacks, _get_component]
15+
_TOOL_PROVIDERS: list[type[MCPToolProvider]] = [
16+
CallbackTools,
17+
GetDashComponentTool,
18+
]
2319

2420

2521
def list_tools() -> ListToolsResult:
2622
"""Build the MCP tools/list response."""
2723
tools = []
28-
for mod in _TOOL_MODULES:
29-
tools.extend(mod.get_tools())
24+
for provider in _TOOL_PROVIDERS:
25+
tools.extend(provider.list_tools())
3026
return ListToolsResult(tools=tools)
3127

3228

3329
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
30+
"""Route a tools/call request by tool name."""
31+
for provider in _TOOL_PROVIDERS:
32+
if tool_name in provider.get_tool_names():
33+
return provider.call_tool(tool_name, arguments)
3934
raise ToolNotFoundError(
4035
f"Tool not found: {tool_name}."
4136
" The app's callbacks may have changed."

dash/mcp/primitives/tools/base.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Base class for MCP tool providers."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from mcp.types import CallToolResult, Tool
8+
9+
10+
class MCPToolProvider:
11+
"""A provider of one or more MCP tools.
12+
13+
Subclasses implement ``list_tools`` to return the tools they provide,
14+
``get_tool_names`` to advertise those names for routing, and
15+
``call_tool`` to execute a tool by name.
16+
"""
17+
18+
@classmethod
19+
def get_tool_names(cls) -> set[str]:
20+
raise NotImplementedError
21+
22+
@classmethod
23+
def list_tools(cls) -> list[Tool]:
24+
raise NotImplementedError
25+
26+
@classmethod
27+
def call_tool(cls, tool_name: str, arguments: dict[str, Any]) -> CallToolResult:
28+
raise NotImplementedError

dash/mcp/primitives/tools/tool_get_dash_component.py

Lines changed: 87 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from dash._layout_utils import find_component
1414
from dash.mcp.types import ComponentPropertyInfo, ComponentQueryResult
1515

16+
from .base import MCPToolProvider
17+
1618

1719
class _ComponentQueryInput(TypedDict):
1820
component_id: Annotated[str, Field(description="The component ID to query")]
@@ -32,92 +34,94 @@ class _ComponentQueryInput(TypedDict):
3234
NAME = "get_dash_component"
3335

3436

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")
37+
class GetDashComponentTool(MCPToolProvider):
38+
"""Inspects a component's properties and its tool relationships."""
39+
40+
@classmethod
41+
def get_tool_names(cls) -> set[str]:
42+
return {NAME}
43+
44+
@classmethod
45+
def list_tools(cls) -> list[Tool]:
46+
return [
47+
Tool(
48+
name=NAME,
49+
description=(
50+
"Get a component's properties, values, and tool relationships. "
51+
"If property is omitted, returns all defined properties. "
52+
"If property is specified, returns only that property. "
53+
"See the dash://components resource for available component IDs."
54+
),
55+
inputSchema=_INPUT_SCHEMA,
56+
outputSchema=_OUTPUT_SCHEMA,
57+
)
58+
]
6159

62-
prop_filter = arguments.get("property", "")
63-
component = find_component(comp_id)
60+
@classmethod
61+
def call_tool(cls, tool_name: str, arguments: dict[str, Any]) -> CallToolResult:
62+
comp_id = arguments.get("component_id", "")
63+
if not comp_id:
64+
raise ValueError("component_id is required")
65+
66+
prop_filter = arguments.get("property", "")
67+
component = find_component(comp_id)
68+
69+
if component is None:
70+
callback_map = get_app().mcp_callback_map
71+
rendering_tools = [
72+
cb.tool_name
73+
for cb in callback_map
74+
if any(out["component_id"] == comp_id for out in cb.outputs)
75+
]
76+
msg = f"Component '{comp_id}' not found in static layout."
77+
if rendering_tools:
78+
msg += f" However, the following tools would modify it: {rendering_tools}."
79+
msg += " Use the dash://components resource to see statically available component IDs."
80+
return CallToolResult(
81+
content=[TextContent(type="text", text=msg)],
82+
isError=True,
83+
)
6484

65-
if component is None:
6685
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-
)
8086

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,
87+
properties: dict[str, ComponentPropertyInfo] = {}
88+
for prop_name in getattr(component, "_prop_names", []):
89+
if prop_filter and prop_name != prop_filter:
90+
continue
91+
92+
value = callback_map.get_initial_value(f"{comp_id}.{prop_name}")
93+
if value is None:
94+
value = getattr(component, prop_name, None)
95+
if value is None:
96+
continue
97+
98+
modified_by: list[str] = []
99+
input_to: list[str] = []
100+
id_and_prop = f"{comp_id}.{prop_name}"
101+
for cb in callback_map:
102+
for out in cb.outputs:
103+
if out["id_and_prop"] == id_and_prop:
104+
modified_by.append(cb.tool_name)
105+
for inp in cb.inputs:
106+
if inp["id_and_prop"] == id_and_prop:
107+
input_to.append(cb.tool_name)
108+
109+
properties[prop_name] = ComponentPropertyInfo(
110+
initial_value=value,
111+
modified_by_tool=modified_by,
112+
input_to_tool=input_to,
113+
)
114+
115+
labels = callback_map.component_label_map.get(comp_id, [])
116+
117+
structured: ComponentQueryResult = ComponentQueryResult(
118+
component_id=comp_id,
119+
component_type=type(component).__name__,
120+
label=labels if labels else None,
121+
properties=properties,
109122
)
110123

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-
)
124+
return CallToolResult(
125+
content=[TextContent(type="text", text=json.dumps(structured, default=str))],
126+
structuredContent=structured,
127+
)
Lines changed: 36 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Dynamic callback tools for MCP.
22
3-
Handles listing, naming, and executing callback-based tools.
3+
Exposes every server-callable callback as an MCP tool.
44
"""
55

66
from __future__ import annotations
@@ -12,36 +12,41 @@
1212
from dash import get_app
1313
from dash.mcp.types import CallbackExecutionError, ToolNotFoundError
1414

15+
from .base import MCPToolProvider
1516
from .results import format_callback_response
1617

1718

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)
19+
class CallbackTools(MCPToolProvider):
20+
"""Exposes every server-callable callback as an MCP tool."""
21+
22+
@classmethod
23+
def get_tool_names(cls) -> set[str]:
24+
return get_app().mcp_callback_map.tool_names
25+
26+
@classmethod
27+
def list_tools(cls) -> list[Tool]:
28+
"""Return one Tool per server-callable callback."""
29+
return get_app().mcp_callback_map.as_mcp_tools()
30+
31+
@classmethod
32+
def call_tool(cls, tool_name: str, arguments: dict[str, Any]) -> CallToolResult:
33+
"""Execute a callback tool by name."""
34+
from .callback_utils import run_callback
35+
36+
callback_map = get_app().mcp_callback_map
37+
cb = callback_map.find_by_tool_name(tool_name)
38+
if cb is None:
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+
)
44+
45+
try:
46+
callback_response = run_callback(cb, arguments)
47+
except CallbackExecutionError as e:
48+
return CallToolResult(
49+
content=[TextContent(type="text", text=str(e))],
50+
isError=True,
51+
)
52+
return format_callback_response(callback_response, cb)

0 commit comments

Comments
 (0)