From 4bd9307e7b0c0e046f78457ca2604f3c4e29bde1 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Wed, 1 Apr 2026 14:23:37 -0600 Subject: [PATCH 1/4] Add result formatters for Plotly figures and tabular data --- dash/mcp/primitives/tools/results/__init__.py | 52 ++++++++++ .../tools/results/result_dataframe.py | 55 +++++++++++ .../tools/results/result_plotly_figure.py | 52 ++++++++++ .../tools/results/test_callback_response.py | 98 +++++++++++++++++++ .../unit/mcp/tools/results/test_dataframe.py | 63 ++++++++++++ .../mcp/tools/results/test_plotly_figure.py | 55 +++++++++++ 6 files changed, 375 insertions(+) create mode 100644 dash/mcp/primitives/tools/results/__init__.py create mode 100644 dash/mcp/primitives/tools/results/result_dataframe.py create mode 100644 dash/mcp/primitives/tools/results/result_plotly_figure.py create mode 100644 tests/unit/mcp/tools/results/test_callback_response.py create mode 100644 tests/unit/mcp/tools/results/test_dataframe.py create mode 100644 tests/unit/mcp/tools/results/test_plotly_figure.py diff --git a/dash/mcp/primitives/tools/results/__init__.py b/dash/mcp/primitives/tools/results/__init__.py new file mode 100644 index 0000000000..e2f91a67a8 --- /dev/null +++ b/dash/mcp/primitives/tools/results/__init__.py @@ -0,0 +1,52 @@ +"""Tool result formatting for MCP tools/call responses. + +Each result formatter shares the same signature: +``(output: MCPOutput, value: Any) -> list[TextContent | ImageContent]`` + +Formatters decide for themselves whether they care about a given output. +The structuredContent is always the full dispatch response. +""" + +from __future__ import annotations + +import json +from typing import Any + +from mcp.types import CallToolResult, TextContent + +from dash.types import CallbackDispatchResponse +from dash.mcp.primitives.tools.callback_adapter import CallbackAdapter + +from .result_dataframe import dataframe_result +from .result_plotly_figure import plotly_figure_result + +_RESULT_FORMATTERS = [ + plotly_figure_result, + dataframe_result, +] + + +def format_callback_response( + response: CallbackDispatchResponse, + callback: CallbackAdapter, +) -> CallToolResult: + """Format a dispatch response as a CallToolResult. + + The response is always returned as structuredContent. Result + formatters are called per output property and may add additional + content items (images, markdown, etc.). + """ + content: list[Any] = [ + TextContent(type="text", text=json.dumps(response, default=str)), + ] + + resp = response.get("response") or {} + for callback_output in callback.outputs: + value = resp.get(callback_output["component_id"], {}).get(callback_output["property"]) + for result_fn in _RESULT_FORMATTERS: + content.extend(result_fn(callback_output, value)) + + return CallToolResult( + content=content, + structuredContent=response, + ) diff --git a/dash/mcp/primitives/tools/results/result_dataframe.py b/dash/mcp/primitives/tools/results/result_dataframe.py new file mode 100644 index 0000000000..652c31589b --- /dev/null +++ b/dash/mcp/primitives/tools/results/result_dataframe.py @@ -0,0 +1,55 @@ +"""Tabular data result: render as a markdown table. + +Detects tabular output by component type and prop name: +- DataTable.data +- AgGrid.rowData +""" + +from __future__ import annotations + +from typing import Any + +from mcp.types import TextContent + +from dash.mcp.types import MCPOutput + +MAX_ROWS = 50 + +_TABULAR_PROPS = { + ("DataTable", "data"), + ("AgGrid", "rowData"), +} + + +def _to_markdown_table(rows: list[dict], max_rows: int = MAX_ROWS) -> str: + """Render a list of row dicts as a markdown table.""" + columns = list(rows[0].keys()) + total = len(rows) + + lines: list[str] = [] + lines.append(f"*{total} rows \u00d7 {len(columns)} columns*") + lines.append("") + lines.append("| " + " | ".join(columns) + " |") + lines.append("| " + " | ".join("---" for _ in columns) + " |") + + for row in rows[:max_rows]: + cells = [ + str(row.get(col, "")).replace("|", "\\|").replace("\n", " ") + for col in columns + ] + lines.append("| " + " | ".join(cells) + " |") + + if total > max_rows: + lines.append(f"\n(\u2026 {total - max_rows} more rows)") + + return "\n".join(lines) + + +def dataframe_result(callback_output: MCPOutput, callback_output_value: Any) -> list: + """Produce a markdown table for tabular component output values.""" + key = (callback_output.get("component_type"), callback_output.get("property")) + if key not in _TABULAR_PROPS: + return [] + if not isinstance(callback_output_value, list) or not callback_output_value or not isinstance(callback_output_value[0], dict): + return [] + return [TextContent(type="text", text=_to_markdown_table(callback_output_value))] diff --git a/dash/mcp/primitives/tools/results/result_plotly_figure.py b/dash/mcp/primitives/tools/results/result_plotly_figure.py new file mode 100644 index 0000000000..d837e17eed --- /dev/null +++ b/dash/mcp/primitives/tools/results/result_plotly_figure.py @@ -0,0 +1,52 @@ +"""Plotly figure tool result: rendered image.""" + +from __future__ import annotations + +import base64 +import logging +from typing import Any + +from mcp.types import ImageContent + +from dash.mcp.types import MCPOutput + +logger = logging.getLogger(__name__) + +IMAGE_WIDTH = 700 +IMAGE_HEIGHT = 450 + + +def _render_image(figure: Any) -> ImageContent | None: + """Render the figure as a base64 PNG ImageContent. + + Returns None if kaleido is not installed. + """ + try: + img_bytes = figure.to_image( + format="png", + width=IMAGE_WIDTH, + height=IMAGE_HEIGHT, + ) + except (ValueError, ImportError): + logger.debug("MCP: kaleido not available, skipping image render") + return None + + b64 = base64.b64encode(img_bytes).decode("ascii") + return ImageContent(type="image", data=b64, mimeType="image/png") + + +def plotly_figure_result(callback_output: MCPOutput, callback_output_value: Any) -> list: + """Produce a rendered PNG for Graph.figure output values.""" + if callback_output.get("component_type") != "Graph" or callback_output.get("property") != "figure": + return [] + if not isinstance(callback_output_value, dict): + return [] + + try: + import plotly.graph_objects as go + except ImportError: + return [] + + fig = go.Figure(callback_output_value) + image = _render_image(fig) + return [image] if image is not None else [] diff --git a/tests/unit/mcp/tools/results/test_callback_response.py b/tests/unit/mcp/tools/results/test_callback_response.py new file mode 100644 index 0000000000..ff8cca5e20 --- /dev/null +++ b/tests/unit/mcp/tools/results/test_callback_response.py @@ -0,0 +1,98 @@ +"""Tests for the callback response formatter.""" + +from unittest.mock import Mock + +from dash.mcp.primitives.tools.results import format_callback_response + + +def _mock_callback(outputs=None): + cb = Mock() + cb.outputs = outputs or [] + return cb + + +class TestFormatCallbackResponse: + def test_wraps_as_structured_content(self): + response = { + "multi": True, + "response": {"out": {"children": "hello"}}, + } + result = format_callback_response(response, _mock_callback()) + assert result.structuredContent == response + + def test_content_has_json_text_fallback(self): + """Per MCP spec, structuredContent SHOULD include a TextContent fallback.""" + response = {"multi": True, "response": {}} + result = format_callback_response(response, _mock_callback()) + assert len(result.content) >= 1 + assert result.content[0].type == "text" + assert '"multi": true' in result.content[0].text + + def test_is_error_defaults_false(self): + response = {"multi": True, "response": {}} + result = format_callback_response(response, _mock_callback()) + assert result.isError is False + + def test_preserves_side_update(self): + response = { + "multi": True, + "response": {"out": {"children": "x"}}, + "sideUpdate": {"other": {"value": 42}}, + } + result = format_callback_response(response, _mock_callback()) + assert result.structuredContent["sideUpdate"] == {"other": {"value": 42}} + + def test_datatable_result_includes_markdown_table(self): + response = { + "multi": True, + "response": { + "my-table": {"data": [{"name": "Alice", "age": 30}]}, + }, + } + outputs = [ + { + "component_id": "my-table", + "component_type": "DataTable", + "property": "data", + "id_and_prop": "my-table.data", + "initial_value": None, + "tool_name": "update", + } + ] + result = format_callback_response(response, _mock_callback(outputs)) + texts = [c.text for c in result.content if c.type == "text"] + assert any("| name | age |" in t for t in texts) + + def test_plotly_figure_includes_image(self): + from unittest.mock import patch + + try: + import plotly.graph_objects as go + except ImportError: + return + + response = { + "multi": True, + "response": { + "my-graph": { + "figure": { + "data": [{"type": "bar", "x": ["A"], "y": [1]}], + "layout": {}, + } + } + }, + } + outputs = [ + { + "component_id": "my-graph", + "component_type": "Graph", + "property": "figure", + "id_and_prop": "my-graph.figure", + "initial_value": None, + "tool_name": "update", + } + ] + with patch.object(go.Figure, "to_image", return_value=b"\x89PNGfake"): + result = format_callback_response(response, _mock_callback(outputs)) + images = [c for c in result.content if c.type == "image"] + assert len(images) == 1 diff --git a/tests/unit/mcp/tools/results/test_dataframe.py b/tests/unit/mcp/tools/results/test_dataframe.py new file mode 100644 index 0000000000..a7f9e42fca --- /dev/null +++ b/tests/unit/mcp/tools/results/test_dataframe.py @@ -0,0 +1,63 @@ +"""Tests for the tabular data result formatter.""" + +from dash.mcp.primitives.tools.results.result_dataframe import ( + MAX_ROWS, + dataframe_result, +) + +EXPECTED_TABLE = ( + "*2 rows \u00d7 2 columns*\n" + "\n" + "| name | age |\n" + "| --- | --- |\n" + "| Alice | 30 |\n" + "| Bob | 25 |" +) + +SAMPLE_ROWS = [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}] + +DATATABLE_OUTPUT = { + "component_type": "DataTable", + "property": "data", + "component_id": "t", + "id_and_prop": "t.data", + "initial_value": None, + "tool_name": "update", +} + +AGGRID_OUTPUT = { + "component_type": "AgGrid", + "property": "rowData", + "component_id": "g", + "id_and_prop": "g.rowData", + "initial_value": None, + "tool_name": "update", +} + + +class TestDataframeResult: + def test_datatable_data_renders_markdown(self): + result = dataframe_result(DATATABLE_OUTPUT, SAMPLE_ROWS) + assert len(result) == 1 + assert result[0].text == EXPECTED_TABLE + + def test_aggrid_rowdata_renders_markdown(self): + result = dataframe_result(AGGRID_OUTPUT, SAMPLE_ROWS) + assert len(result) == 1 + assert result[0].text == EXPECTED_TABLE + + def test_ignores_non_tabular_props(self): + non_tabular = {**DATATABLE_OUTPUT, "property": "columns"} + assert dataframe_result(non_tabular, SAMPLE_ROWS) == [] + + def test_ignores_empty_or_non_dict_rows(self): + assert dataframe_result(DATATABLE_OUTPUT, []) == [] + assert dataframe_result(DATATABLE_OUTPUT, ["a", "b"]) == [] + + def test_truncates_large_tables(self): + rows = [{"i": n} for n in range(MAX_ROWS + 50)] + result = dataframe_result(DATATABLE_OUTPUT, rows) + text = result[0].text + assert f"| {MAX_ROWS - 1} |" in text + assert f"| {MAX_ROWS} |" not in text + assert "50 more rows" in text diff --git a/tests/unit/mcp/tools/results/test_plotly_figure.py b/tests/unit/mcp/tools/results/test_plotly_figure.py new file mode 100644 index 0000000000..8e336ba687 --- /dev/null +++ b/tests/unit/mcp/tools/results/test_plotly_figure.py @@ -0,0 +1,55 @@ +"""Tests for the Plotly figure tool result formatter.""" + +import base64 +from unittest.mock import patch + +import pytest + +from dash.mcp.primitives.tools.results.result_plotly_figure import ( + plotly_figure_result, +) + +go = pytest.importorskip("plotly.graph_objects") + +FAKE_PNG = b"\x89PNG\r\n\x1a\nfakedata" +FAKE_B64 = base64.b64encode(FAKE_PNG).decode("ascii") + +GRAPH_FIGURE_OUTPUT = { + "component_type": "Graph", + "property": "figure", + "component_id": "g", + "id_and_prop": "g.figure", + "initial_value": None, + "tool_name": "update", +} + + +class TestPlotlyFigureResult: + def test_returns_image_when_kaleido_available(self): + fig_dict = go.Figure(data=[go.Bar(x=["A", "B"], y=[1, 2])]).to_plotly_json() + with patch.object(go.Figure, "to_image", return_value=FAKE_PNG): + result = plotly_figure_result(GRAPH_FIGURE_OUTPUT, fig_dict) + assert len(result) == 1 + assert result[0].type == "image" + assert result[0].data == FAKE_B64 + + def test_returns_empty_when_kaleido_unavailable(self): + fig_dict = go.Figure(data=[go.Bar(x=["A", "B"], y=[1, 2])]).to_plotly_json() + with patch.object(go.Figure, "to_image", side_effect=ImportError): + result = plotly_figure_result(GRAPH_FIGURE_OUTPUT, fig_dict) + assert result == [] + + def test_ignores_non_graph_components(self): + output = { + **GRAPH_FIGURE_OUTPUT, + "component_type": "Div", + "property": "children", + } + assert plotly_figure_result(output, {}) == [] + + def test_ignores_non_figure_props(self): + output = {**GRAPH_FIGURE_OUTPUT, "property": "clickData"} + assert plotly_figure_result(output, {}) == [] + + def test_ignores_non_dict_values(self): + assert plotly_figure_result(GRAPH_FIGURE_OUTPUT, "not a dict") == [] From fb4fc3985a8e38557e5daf8e93f476f05422a8a4 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Thu, 16 Apr 2026 13:34:23 -0600 Subject: [PATCH 2/4] Update type names --- dash/mcp/primitives/tools/results/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dash/mcp/primitives/tools/results/__init__.py b/dash/mcp/primitives/tools/results/__init__.py index e2f91a67a8..ed21178c0a 100644 --- a/dash/mcp/primitives/tools/results/__init__.py +++ b/dash/mcp/primitives/tools/results/__init__.py @@ -14,7 +14,7 @@ from mcp.types import CallToolResult, TextContent -from dash.types import CallbackDispatchResponse +from dash.types import CallbackExecutionResponse from dash.mcp.primitives.tools.callback_adapter import CallbackAdapter from .result_dataframe import dataframe_result @@ -27,7 +27,7 @@ def format_callback_response( - response: CallbackDispatchResponse, + response: CallbackExecutionResponse, callback: CallbackAdapter, ) -> CallToolResult: """Format a dispatch response as a CallToolResult. From 7518ca09c4de6666e32e4c80d3f6db674aee991e Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Thu, 16 Apr 2026 13:43:01 -0600 Subject: [PATCH 3/4] Refactor tool result formatters to use a base class (just as resources do) --- dash/mcp/primitives/tools/results/__init__.py | 24 ++++++------- dash/mcp/primitives/tools/results/base.py | 24 +++++++++++++ .../tools/results/result_dataframe.py | 27 +++++++++----- .../tools/results/result_plotly_figure.py | 35 +++++++++++-------- .../unit/mcp/tools/results/test_dataframe.py | 14 ++++---- .../mcp/tools/results/test_plotly_figure.py | 12 +++---- 6 files changed, 88 insertions(+), 48 deletions(-) create mode 100644 dash/mcp/primitives/tools/results/base.py diff --git a/dash/mcp/primitives/tools/results/__init__.py b/dash/mcp/primitives/tools/results/__init__.py index ed21178c0a..c0232d6028 100644 --- a/dash/mcp/primitives/tools/results/__init__.py +++ b/dash/mcp/primitives/tools/results/__init__.py @@ -1,10 +1,7 @@ """Tool result formatting for MCP tools/call responses. -Each result formatter shares the same signature: -``(output: MCPOutput, value: Any) -> list[TextContent | ImageContent]`` - -Formatters decide for themselves whether they care about a given output. -The structuredContent is always the full dispatch response. +Each formatter is a ``ResultFormatter`` subclass that can enrich +a tool result with additional content. All formatters are accumulated. """ from __future__ import annotations @@ -17,12 +14,13 @@ from dash.types import CallbackExecutionResponse from dash.mcp.primitives.tools.callback_adapter import CallbackAdapter -from .result_dataframe import dataframe_result -from .result_plotly_figure import plotly_figure_result +from .base import ResultFormatter +from .result_dataframe import DataFrameResult +from .result_plotly_figure import PlotlyFigureResult -_RESULT_FORMATTERS = [ - plotly_figure_result, - dataframe_result, +_RESULT_FORMATTERS: list[type[ResultFormatter]] = [ + PlotlyFigureResult, + DataFrameResult, ] @@ -30,7 +28,7 @@ def format_callback_response( response: CallbackExecutionResponse, callback: CallbackAdapter, ) -> CallToolResult: - """Format a dispatch response as a CallToolResult. + """Format a callback response as a CallToolResult. The response is always returned as structuredContent. Result formatters are called per output property and may add additional @@ -43,8 +41,8 @@ def format_callback_response( resp = response.get("response") or {} for callback_output in callback.outputs: value = resp.get(callback_output["component_id"], {}).get(callback_output["property"]) - for result_fn in _RESULT_FORMATTERS: - content.extend(result_fn(callback_output, value)) + for formatter in _RESULT_FORMATTERS: + content.extend(formatter.format(callback_output, value)) return CallToolResult( content=content, diff --git a/dash/mcp/primitives/tools/results/base.py b/dash/mcp/primitives/tools/results/base.py new file mode 100644 index 0000000000..1f7714ff6b --- /dev/null +++ b/dash/mcp/primitives/tools/results/base.py @@ -0,0 +1,24 @@ +"""Base class for result formatters.""" + +from __future__ import annotations + +from typing import Any + +from mcp.types import ImageContent, TextContent + +from dash.mcp.types import MCPOutput + + +class ResultFormatter: + """A formatter that can enrich an MCP tool result with additional content. + + Subclasses implement ``format`` to return content items (text, images) + for a specific callback output. All formatters are accumulated — every + formatter can add content to the overall tool result. + """ + + @classmethod + def format( + cls, output: MCPOutput, returned_output_value: Any + ) -> list[TextContent | ImageContent]: + raise NotImplementedError diff --git a/dash/mcp/primitives/tools/results/result_dataframe.py b/dash/mcp/primitives/tools/results/result_dataframe.py index 652c31589b..b7113f5d82 100644 --- a/dash/mcp/primitives/tools/results/result_dataframe.py +++ b/dash/mcp/primitives/tools/results/result_dataframe.py @@ -9,10 +9,12 @@ from typing import Any -from mcp.types import TextContent +from mcp.types import ImageContent, TextContent from dash.mcp.types import MCPOutput +from .base import ResultFormatter + MAX_ROWS = 50 _TABULAR_PROPS = { @@ -45,11 +47,20 @@ def _to_markdown_table(rows: list[dict], max_rows: int = MAX_ROWS) -> str: return "\n".join(lines) -def dataframe_result(callback_output: MCPOutput, callback_output_value: Any) -> list: +class DataFrameResult(ResultFormatter): """Produce a markdown table for tabular component output values.""" - key = (callback_output.get("component_type"), callback_output.get("property")) - if key not in _TABULAR_PROPS: - return [] - if not isinstance(callback_output_value, list) or not callback_output_value or not isinstance(callback_output_value[0], dict): - return [] - return [TextContent(type="text", text=_to_markdown_table(callback_output_value))] + + @classmethod + def format( + cls, output: MCPOutput, returned_output_value: Any + ) -> list[TextContent | ImageContent]: + key = (output.get("component_type"), output.get("property")) + if key not in _TABULAR_PROPS: + return [] + if ( + not isinstance(returned_output_value, list) + or not returned_output_value + or not isinstance(returned_output_value[0], dict) + ): + return [] + return [TextContent(type="text", text=_to_markdown_table(returned_output_value))] diff --git a/dash/mcp/primitives/tools/results/result_plotly_figure.py b/dash/mcp/primitives/tools/results/result_plotly_figure.py index d837e17eed..fff3a3de89 100644 --- a/dash/mcp/primitives/tools/results/result_plotly_figure.py +++ b/dash/mcp/primitives/tools/results/result_plotly_figure.py @@ -6,10 +6,12 @@ import logging from typing import Any -from mcp.types import ImageContent +from mcp.types import ImageContent, TextContent from dash.mcp.types import MCPOutput +from .base import ResultFormatter + logger = logging.getLogger(__name__) IMAGE_WIDTH = 700 @@ -35,18 +37,23 @@ def _render_image(figure: Any) -> ImageContent | None: return ImageContent(type="image", data=b64, mimeType="image/png") -def plotly_figure_result(callback_output: MCPOutput, callback_output_value: Any) -> list: +class PlotlyFigureResult(ResultFormatter): """Produce a rendered PNG for Graph.figure output values.""" - if callback_output.get("component_type") != "Graph" or callback_output.get("property") != "figure": - return [] - if not isinstance(callback_output_value, dict): - return [] - - try: - import plotly.graph_objects as go - except ImportError: - return [] - fig = go.Figure(callback_output_value) - image = _render_image(fig) - return [image] if image is not None else [] + @classmethod + def format( + cls, output: MCPOutput, returned_output_value: Any + ) -> list[TextContent | ImageContent]: + if output.get("component_type") != "Graph" or output.get("property") != "figure": + return [] + if not isinstance(returned_output_value, dict): + return [] + + try: + import plotly.graph_objects as go + except ImportError: + return [] + + fig = go.Figure(returned_output_value) + image = _render_image(fig) + return [image] if image is not None else [] diff --git a/tests/unit/mcp/tools/results/test_dataframe.py b/tests/unit/mcp/tools/results/test_dataframe.py index a7f9e42fca..65aef74d31 100644 --- a/tests/unit/mcp/tools/results/test_dataframe.py +++ b/tests/unit/mcp/tools/results/test_dataframe.py @@ -2,7 +2,7 @@ from dash.mcp.primitives.tools.results.result_dataframe import ( MAX_ROWS, - dataframe_result, + DataFrameResult, ) EXPECTED_TABLE = ( @@ -37,26 +37,26 @@ class TestDataframeResult: def test_datatable_data_renders_markdown(self): - result = dataframe_result(DATATABLE_OUTPUT, SAMPLE_ROWS) + result = DataFrameResult.format(DATATABLE_OUTPUT, SAMPLE_ROWS) assert len(result) == 1 assert result[0].text == EXPECTED_TABLE def test_aggrid_rowdata_renders_markdown(self): - result = dataframe_result(AGGRID_OUTPUT, SAMPLE_ROWS) + result = DataFrameResult.format(AGGRID_OUTPUT, SAMPLE_ROWS) assert len(result) == 1 assert result[0].text == EXPECTED_TABLE def test_ignores_non_tabular_props(self): non_tabular = {**DATATABLE_OUTPUT, "property": "columns"} - assert dataframe_result(non_tabular, SAMPLE_ROWS) == [] + assert DataFrameResult.format(non_tabular, SAMPLE_ROWS) == [] def test_ignores_empty_or_non_dict_rows(self): - assert dataframe_result(DATATABLE_OUTPUT, []) == [] - assert dataframe_result(DATATABLE_OUTPUT, ["a", "b"]) == [] + assert DataFrameResult.format(DATATABLE_OUTPUT, []) == [] + assert DataFrameResult.format(DATATABLE_OUTPUT, ["a", "b"]) == [] def test_truncates_large_tables(self): rows = [{"i": n} for n in range(MAX_ROWS + 50)] - result = dataframe_result(DATATABLE_OUTPUT, rows) + result = DataFrameResult.format(DATATABLE_OUTPUT, rows) text = result[0].text assert f"| {MAX_ROWS - 1} |" in text assert f"| {MAX_ROWS} |" not in text diff --git a/tests/unit/mcp/tools/results/test_plotly_figure.py b/tests/unit/mcp/tools/results/test_plotly_figure.py index 8e336ba687..e3c42af303 100644 --- a/tests/unit/mcp/tools/results/test_plotly_figure.py +++ b/tests/unit/mcp/tools/results/test_plotly_figure.py @@ -6,7 +6,7 @@ import pytest from dash.mcp.primitives.tools.results.result_plotly_figure import ( - plotly_figure_result, + PlotlyFigureResult, ) go = pytest.importorskip("plotly.graph_objects") @@ -28,7 +28,7 @@ class TestPlotlyFigureResult: def test_returns_image_when_kaleido_available(self): fig_dict = go.Figure(data=[go.Bar(x=["A", "B"], y=[1, 2])]).to_plotly_json() with patch.object(go.Figure, "to_image", return_value=FAKE_PNG): - result = plotly_figure_result(GRAPH_FIGURE_OUTPUT, fig_dict) + result = PlotlyFigureResult.format(GRAPH_FIGURE_OUTPUT, fig_dict) assert len(result) == 1 assert result[0].type == "image" assert result[0].data == FAKE_B64 @@ -36,7 +36,7 @@ def test_returns_image_when_kaleido_available(self): def test_returns_empty_when_kaleido_unavailable(self): fig_dict = go.Figure(data=[go.Bar(x=["A", "B"], y=[1, 2])]).to_plotly_json() with patch.object(go.Figure, "to_image", side_effect=ImportError): - result = plotly_figure_result(GRAPH_FIGURE_OUTPUT, fig_dict) + result = PlotlyFigureResult.format(GRAPH_FIGURE_OUTPUT, fig_dict) assert result == [] def test_ignores_non_graph_components(self): @@ -45,11 +45,11 @@ def test_ignores_non_graph_components(self): "component_type": "Div", "property": "children", } - assert plotly_figure_result(output, {}) == [] + assert PlotlyFigureResult.format(output, {}) == [] def test_ignores_non_figure_props(self): output = {**GRAPH_FIGURE_OUTPUT, "property": "clickData"} - assert plotly_figure_result(output, {}) == [] + assert PlotlyFigureResult.format(output, {}) == [] def test_ignores_non_dict_values(self): - assert plotly_figure_result(GRAPH_FIGURE_OUTPUT, "not a dict") == [] + assert PlotlyFigureResult.format(GRAPH_FIGURE_OUTPUT, "not a dict") == [] From 0e54d74471862dfb6709c57ff7dd7c0d975f42e0 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Thu, 16 Apr 2026 17:19:34 -0600 Subject: [PATCH 4/4] lint --- dash/mcp/primitives/tools/results/__init__.py | 4 +++- dash/mcp/primitives/tools/results/result_dataframe.py | 4 +++- dash/mcp/primitives/tools/results/result_plotly_figure.py | 5 ++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/dash/mcp/primitives/tools/results/__init__.py b/dash/mcp/primitives/tools/results/__init__.py index c0232d6028..ae3517919c 100644 --- a/dash/mcp/primitives/tools/results/__init__.py +++ b/dash/mcp/primitives/tools/results/__init__.py @@ -40,7 +40,9 @@ def format_callback_response( resp = response.get("response") or {} for callback_output in callback.outputs: - value = resp.get(callback_output["component_id"], {}).get(callback_output["property"]) + value = resp.get(callback_output["component_id"], {}).get( + callback_output["property"] + ) for formatter in _RESULT_FORMATTERS: content.extend(formatter.format(callback_output, value)) diff --git a/dash/mcp/primitives/tools/results/result_dataframe.py b/dash/mcp/primitives/tools/results/result_dataframe.py index b7113f5d82..04b1d84b3e 100644 --- a/dash/mcp/primitives/tools/results/result_dataframe.py +++ b/dash/mcp/primitives/tools/results/result_dataframe.py @@ -63,4 +63,6 @@ def format( or not isinstance(returned_output_value[0], dict) ): return [] - return [TextContent(type="text", text=_to_markdown_table(returned_output_value))] + return [ + TextContent(type="text", text=_to_markdown_table(returned_output_value)) + ] diff --git a/dash/mcp/primitives/tools/results/result_plotly_figure.py b/dash/mcp/primitives/tools/results/result_plotly_figure.py index fff3a3de89..ad2c057f89 100644 --- a/dash/mcp/primitives/tools/results/result_plotly_figure.py +++ b/dash/mcp/primitives/tools/results/result_plotly_figure.py @@ -44,7 +44,10 @@ class PlotlyFigureResult(ResultFormatter): def format( cls, output: MCPOutput, returned_output_value: Any ) -> list[TextContent | ImageContent]: - if output.get("component_type") != "Graph" or output.get("property") != "figure": + if ( + output.get("component_type") != "Graph" + or output.get("property") != "figure" + ): return [] if not isinstance(returned_output_value, dict): return []