-
-
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
base: mcp
Are you sure you want to change the base?
Changes from all commits
a0acd99
9786f54
23c3281
57f5cb9
7af6f3f
72073ac
b7e44a4
8f9d5a0
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). |
||
| 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,63 @@ | ||||||||||
| """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 ..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 = len(rows) | ||||||||||
|
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.
Suggested change
|
||||||||||
|
|
||||||||||
| lines: list[str] = [] | ||||||||||
| lines.append(f"*{total} rows \u00d7 {len(columns)} columns*") | ||||||||||
| lines.append("") | ||||||||||
| lines.append("| " + " | ".join(columns) + " |") | ||||||||||
|
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. If wouldn't save you much, but you could avoid adding the outer |s and still comply with the markdown spec. But every token counts, right? |
||||||||||
| 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]: | ||||||||||
|
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. Since only |
||||||||||
| if not TABULAR.matches(output.get("component_type"), output["property"]): | ||||||||||
| return [] | ||||||||||
| if ( | ||||||||||
| not isinstance(returned_output_value, list) | ||||||||||
| or not returned_output_value | ||||||||||
|
Comment on lines
+56
to
+57
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.
Suggested change
|
||||||||||
| 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,56 @@ | ||||||||||||
| """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. | ||||||||||||
|
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. Typical for this PR:
Suggested change
|
||||||||||||
|
|
||||||||||||
| 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 isinstance(returned_output_value, dict): | ||||||||||||
|
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.
Suggested change
|
||||||||||||
| 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?