Skip to content
2 changes: 1 addition & 1 deletion dash/mcp/primitives/tools/prop_roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def _compute_dropdown_value_schema(param: MCPInput) -> dict[str, Any]:
description="Returns formatted text",
)

GENERIC_FIGURE = PropRole(
PLOTLY_FIGURE = PropRole(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this change necessary?

Copy link
Copy Markdown
Contributor Author

@KoolADE85 KoolADE85 May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a style choice: I wanted it to read more clearly when it's subsequently used in the result formatter, that we are actually concerned with formatting Plotly figures, not more "generic" figures.

identifiers={(ANY_COMPONENT, "figure")},
description="Returns chart/visualization data",
input_schema={
Expand Down
52 changes: 52 additions & 0 deletions dash/mcp/primitives/tools/results/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Tool result formatting for MCP tools/call responses.

Each formatter is a ``ResultFormatter`` subclass that can enrich
a tool result with additional content. All formatters are accumulated.
"""

from __future__ import annotations

import json
from typing import Any

from mcp.types import CallToolResult, TextContent

from dash.types import CallbackExecutionResponse
from dash.mcp.primitives.tools.callback_adapter import CallbackAdapter

from .base import ResultFormatter
from .result_dataframe import DataFrameResult
from .result_plotly_figure import PlotlyFigureResult

_RESULT_FORMATTERS: list[type[ResultFormatter]] = [
PlotlyFigureResult,
DataFrameResult,
]


def format_callback_response(
response: CallbackExecutionResponse,
callback: CallbackAdapter,
) -> 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
content items (images, markdown, etc.).
"""
content: list[Any] = [
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would using a dict instead of a list make more sense here? Then you wouldn't have to iterate through the list down below (assuming you could figure out which formatter to use before calling it).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about this further, I'd like to keep the current structure for two reasons:

  1. consistency: the pattern of "iterate providers, each decides if it applies" is already used for input schemas, tool descriptions, and resource providers; I think it's valuable to keep a consistent pattern here too.
  2. flexibility: iterating lets each formatter decide dynamically whether it applies. While it's fairly static today, we are leaving room for multiple formatters to enrich the same output without restructuring later.

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 formatter in _RESULT_FORMATTERS:
content.extend(formatter.format(callback_output, value))

return CallToolResult(
content=content,
structuredContent=response,
)
24 changes: 24 additions & 0 deletions dash/mcp/primitives/tools/results/base.py
Original file line number Diff line number Diff line change
@@ -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
61 changes: 61 additions & 0 deletions dash/mcp/primitives/tools/results/result_dataframe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""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

from ..prop_roles import TABULAR
from .base import ResultFormatter

MAX_ROWS = 50


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_rows = len(rows)

lines: list[str] = []
lines.append(f"*{total_rows} 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_rows > max_rows:
lines.append(f"\n(\u2026 {total_rows - max_rows} more rows)")

return "\n".join(lines)


class DataFrameResult(ResultFormatter):
"""Produce a markdown table for tabular component output values."""

@classmethod
def format(cls, output: MCPOutput, returned_output_value: Any) -> list[TextContent]:
if not TABULAR.matches(output.get("component_type"), output["property"]):
return []
if (
not returned_output_value
or not isinstance(returned_output_value, list)
or not isinstance(returned_output_value[0], dict)
):
return []
return [
TextContent(type="text", text=_to_markdown_table(returned_output_value))
]
57 changes: 57 additions & 0 deletions dash/mcp/primitives/tools/results/result_plotly_figure.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Plotly figure tool result: rendered image."""

from __future__ import annotations

import base64
import logging
from typing import Any

import plotly.graph_objects as go # type: ignore[import-untyped]
from mcp.types import ImageContent, TextContent

from dash.mcp.types import MCPOutput

from ..prop_roles import PLOTLY_FIGURE
from .base import ResultFormatter

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")


class PlotlyFigureResult(ResultFormatter):
"""Produce a rendered PNG for Graph.figure output values."""

@classmethod
def format(
cls, output: MCPOutput, returned_output_value: Any
) -> list[TextContent | ImageContent]:
if not PLOTLY_FIGURE.matches(output.get("component_type"), output["property"]):
return []
if not returned_output_value or not isinstance(returned_output_value, dict):
return []

fig = go.Figure(returned_output_value)
image = _render_image(fig)
return [image] if image is not None else []
Loading
Loading