Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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] = [
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
68 changes: 68 additions & 0 deletions dash/mcp/primitives/tools/results/result_dataframe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""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 ImageContent, TextContent

from dash.mcp.types import MCPOutput

from .base import ResultFormatter

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)


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

@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))
]
58 changes: 58 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,58 @@
"""Plotly figure tool result: rendered image."""

from __future__ import annotations

import base64
import logging
from typing import Any

import plotly.graph_objects as go
from mcp.types import ImageContent, TextContent

from dash.mcp.types import MCPOutput

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 (
output.get("component_type") != "Graph"
or output.get("property") != "figure"
):
return []
if 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