-
-
Notifications
You must be signed in to change notification settings - Fork 2.3k
MCP Server Part 6: Format callback results for LLM consumption #3748
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a0acd99
9786f54
23c3281
57f5cb9
7af6f3f
72073ac
b7e44a4
8f9d5a0
133ef93
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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] = [ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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).
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
|
||
| 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, | ||
| ) | ||
| 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 |
| 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)) | ||
| ] |
| 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 [] |
There was a problem hiding this comment.
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?
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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.