Skip to content

Commit 91ff82e

Browse files
committed
Refactor tool result formatters to use a base class (just as resources do)
1 parent 8c9ea99 commit 91ff82e

6 files changed

Lines changed: 88 additions & 48 deletions

File tree

dash/mcp/primitives/tools/results/__init__.py

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
"""Tool result formatting for MCP tools/call responses.
22
3-
Each result formatter shares the same signature:
4-
``(output: MCPOutput, value: Any) -> list[TextContent | ImageContent]``
5-
6-
Formatters decide for themselves whether they care about a given output.
7-
The structuredContent is always the full dispatch response.
3+
Each formatter is a ``ResultFormatter`` subclass that can enrich
4+
a tool result with additional content. All formatters are accumulated.
85
"""
96

107
from __future__ import annotations
@@ -17,20 +14,21 @@
1714
from dash.types import CallbackExecutionResponse
1815
from dash.mcp.primitives.tools.callback_adapter import CallbackAdapter
1916

20-
from .result_dataframe import dataframe_result
21-
from .result_plotly_figure import plotly_figure_result
17+
from .base import ResultFormatter
18+
from .result_dataframe import DataFrameResult
19+
from .result_plotly_figure import PlotlyFigureResult
2220

23-
_RESULT_FORMATTERS = [
24-
plotly_figure_result,
25-
dataframe_result,
21+
_RESULT_FORMATTERS: list[type[ResultFormatter]] = [
22+
PlotlyFigureResult,
23+
DataFrameResult,
2624
]
2725

2826

2927
def format_callback_response(
3028
response: CallbackExecutionResponse,
3129
callback: CallbackAdapter,
3230
) -> CallToolResult:
33-
"""Format a dispatch response as a CallToolResult.
31+
"""Format a callback response as a CallToolResult.
3432
3533
The response is always returned as structuredContent. Result
3634
formatters are called per output property and may add additional
@@ -43,8 +41,8 @@ def format_callback_response(
4341
resp = response.get("response") or {}
4442
for callback_output in callback.outputs:
4543
value = resp.get(callback_output["component_id"], {}).get(callback_output["property"])
46-
for result_fn in _RESULT_FORMATTERS:
47-
content.extend(result_fn(callback_output, value))
44+
for formatter in _RESULT_FORMATTERS:
45+
content.extend(formatter.format(callback_output, value))
4846

4947
return CallToolResult(
5048
content=content,
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Base class for result formatters."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from mcp.types import ImageContent, TextContent
8+
9+
from dash.mcp.types import MCPOutput
10+
11+
12+
class ResultFormatter:
13+
"""A formatter that can enrich an MCP tool result with additional content.
14+
15+
Subclasses implement ``format`` to return content items (text, images)
16+
for a specific callback output. All formatters are accumulated — every
17+
formatter can add content to the overall tool result.
18+
"""
19+
20+
@classmethod
21+
def format(
22+
cls, output: MCPOutput, returned_output_value: Any
23+
) -> list[TextContent | ImageContent]:
24+
raise NotImplementedError

dash/mcp/primitives/tools/results/result_dataframe.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@
99

1010
from typing import Any
1111

12-
from mcp.types import TextContent
12+
from mcp.types import ImageContent, TextContent
1313

1414
from dash.mcp.types import MCPOutput
1515

16+
from .base import ResultFormatter
17+
1618
MAX_ROWS = 50
1719

1820
_TABULAR_PROPS = {
@@ -45,11 +47,20 @@ def _to_markdown_table(rows: list[dict], max_rows: int = MAX_ROWS) -> str:
4547
return "\n".join(lines)
4648

4749

48-
def dataframe_result(callback_output: MCPOutput, callback_output_value: Any) -> list:
50+
class DataFrameResult(ResultFormatter):
4951
"""Produce a markdown table for tabular component output values."""
50-
key = (callback_output.get("component_type"), callback_output.get("property"))
51-
if key not in _TABULAR_PROPS:
52-
return []
53-
if not isinstance(callback_output_value, list) or not callback_output_value or not isinstance(callback_output_value[0], dict):
54-
return []
55-
return [TextContent(type="text", text=_to_markdown_table(callback_output_value))]
52+
53+
@classmethod
54+
def format(
55+
cls, output: MCPOutput, returned_output_value: Any
56+
) -> list[TextContent | ImageContent]:
57+
key = (output.get("component_type"), output.get("property"))
58+
if key not in _TABULAR_PROPS:
59+
return []
60+
if (
61+
not isinstance(returned_output_value, list)
62+
or not returned_output_value
63+
or not isinstance(returned_output_value[0], dict)
64+
):
65+
return []
66+
return [TextContent(type="text", text=_to_markdown_table(returned_output_value))]

dash/mcp/primitives/tools/results/result_plotly_figure.py

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
import logging
77
from typing import Any
88

9-
from mcp.types import ImageContent
9+
from mcp.types import ImageContent, TextContent
1010

1111
from dash.mcp.types import MCPOutput
1212

13+
from .base import ResultFormatter
14+
1315
logger = logging.getLogger(__name__)
1416

1517
IMAGE_WIDTH = 700
@@ -35,18 +37,23 @@ def _render_image(figure: Any) -> ImageContent | None:
3537
return ImageContent(type="image", data=b64, mimeType="image/png")
3638

3739

38-
def plotly_figure_result(callback_output: MCPOutput, callback_output_value: Any) -> list:
40+
class PlotlyFigureResult(ResultFormatter):
3941
"""Produce a rendered PNG for Graph.figure output values."""
40-
if callback_output.get("component_type") != "Graph" or callback_output.get("property") != "figure":
41-
return []
42-
if not isinstance(callback_output_value, dict):
43-
return []
44-
45-
try:
46-
import plotly.graph_objects as go
47-
except ImportError:
48-
return []
4942

50-
fig = go.Figure(callback_output_value)
51-
image = _render_image(fig)
52-
return [image] if image is not None else []
43+
@classmethod
44+
def format(
45+
cls, output: MCPOutput, returned_output_value: Any
46+
) -> list[TextContent | ImageContent]:
47+
if output.get("component_type") != "Graph" or output.get("property") != "figure":
48+
return []
49+
if not isinstance(returned_output_value, dict):
50+
return []
51+
52+
try:
53+
import plotly.graph_objects as go
54+
except ImportError:
55+
return []
56+
57+
fig = go.Figure(returned_output_value)
58+
image = _render_image(fig)
59+
return [image] if image is not None else []

tests/unit/mcp/tools/results/test_dataframe.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from dash.mcp.primitives.tools.results.result_dataframe import (
44
MAX_ROWS,
5-
dataframe_result,
5+
DataFrameResult,
66
)
77

88
EXPECTED_TABLE = (
@@ -37,26 +37,26 @@
3737

3838
class TestDataframeResult:
3939
def test_datatable_data_renders_markdown(self):
40-
result = dataframe_result(DATATABLE_OUTPUT, SAMPLE_ROWS)
40+
result = DataFrameResult.format(DATATABLE_OUTPUT, SAMPLE_ROWS)
4141
assert len(result) == 1
4242
assert result[0].text == EXPECTED_TABLE
4343

4444
def test_aggrid_rowdata_renders_markdown(self):
45-
result = dataframe_result(AGGRID_OUTPUT, SAMPLE_ROWS)
45+
result = DataFrameResult.format(AGGRID_OUTPUT, SAMPLE_ROWS)
4646
assert len(result) == 1
4747
assert result[0].text == EXPECTED_TABLE
4848

4949
def test_ignores_non_tabular_props(self):
5050
non_tabular = {**DATATABLE_OUTPUT, "property": "columns"}
51-
assert dataframe_result(non_tabular, SAMPLE_ROWS) == []
51+
assert DataFrameResult.format(non_tabular, SAMPLE_ROWS) == []
5252

5353
def test_ignores_empty_or_non_dict_rows(self):
54-
assert dataframe_result(DATATABLE_OUTPUT, []) == []
55-
assert dataframe_result(DATATABLE_OUTPUT, ["a", "b"]) == []
54+
assert DataFrameResult.format(DATATABLE_OUTPUT, []) == []
55+
assert DataFrameResult.format(DATATABLE_OUTPUT, ["a", "b"]) == []
5656

5757
def test_truncates_large_tables(self):
5858
rows = [{"i": n} for n in range(MAX_ROWS + 50)]
59-
result = dataframe_result(DATATABLE_OUTPUT, rows)
59+
result = DataFrameResult.format(DATATABLE_OUTPUT, rows)
6060
text = result[0].text
6161
assert f"| {MAX_ROWS - 1} |" in text
6262
assert f"| {MAX_ROWS} |" not in text

tests/unit/mcp/tools/results/test_plotly_figure.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import pytest
77

88
from dash.mcp.primitives.tools.results.result_plotly_figure import (
9-
plotly_figure_result,
9+
PlotlyFigureResult,
1010
)
1111

1212
go = pytest.importorskip("plotly.graph_objects")
@@ -28,15 +28,15 @@ class TestPlotlyFigureResult:
2828
def test_returns_image_when_kaleido_available(self):
2929
fig_dict = go.Figure(data=[go.Bar(x=["A", "B"], y=[1, 2])]).to_plotly_json()
3030
with patch.object(go.Figure, "to_image", return_value=FAKE_PNG):
31-
result = plotly_figure_result(GRAPH_FIGURE_OUTPUT, fig_dict)
31+
result = PlotlyFigureResult.format(GRAPH_FIGURE_OUTPUT, fig_dict)
3232
assert len(result) == 1
3333
assert result[0].type == "image"
3434
assert result[0].data == FAKE_B64
3535

3636
def test_returns_empty_when_kaleido_unavailable(self):
3737
fig_dict = go.Figure(data=[go.Bar(x=["A", "B"], y=[1, 2])]).to_plotly_json()
3838
with patch.object(go.Figure, "to_image", side_effect=ImportError):
39-
result = plotly_figure_result(GRAPH_FIGURE_OUTPUT, fig_dict)
39+
result = PlotlyFigureResult.format(GRAPH_FIGURE_OUTPUT, fig_dict)
4040
assert result == []
4141

4242
def test_ignores_non_graph_components(self):
@@ -45,11 +45,11 @@ def test_ignores_non_graph_components(self):
4545
"component_type": "Div",
4646
"property": "children",
4747
}
48-
assert plotly_figure_result(output, {}) == []
48+
assert PlotlyFigureResult.format(output, {}) == []
4949

5050
def test_ignores_non_figure_props(self):
5151
output = {**GRAPH_FIGURE_OUTPUT, "property": "clickData"}
52-
assert plotly_figure_result(output, {}) == []
52+
assert PlotlyFigureResult.format(output, {}) == []
5353

5454
def test_ignores_non_dict_values(self):
55-
assert plotly_figure_result(GRAPH_FIGURE_OUTPUT, "not a dict") == []
55+
assert PlotlyFigureResult.format(GRAPH_FIGURE_OUTPUT, "not a dict") == []

0 commit comments

Comments
 (0)