Skip to content

Tool results

Every tool in the platform returns two answers at once, for two different readers:

  • content: human-readable text. This is what the LLM reads to compose its reply. Always required.
  • structuredContent: a JSON payload. This is what the UI (the chat gateway) reads to render source links, tables and charts.

Only content reaches the AI

The gateway feeds content back to the model, but it renders table and charts from structuredContent directly to the screen, without passing them through the model. What users see is exactly what your tool computed. Put the numbers you want trusted in structuredContent.

What goes in structuredContent

Field Type Required What it does
sources list yes Links to the original data. Rendered as source links under the answer.
table list of rows no Tabular data (first row is the header). Rendered as an HTML table.
charts list no Chart.js data and configuration. Rendered as charts. (In development.)
force string no Text shown to the user exactly as-is, bypassing the LLM entirely.

Example with a table

from mcp.types import CallToolResult, TextContent
from mcp_server import DataToolOutput

@registry.tool()
def list_cities() -> DataToolOutput:
    """Return the top 3 cities by population."""
    return CallToolResult(
        content=[TextContent(type="text", text=(
            "The 3 most populated cities are Tokyo (37,400,068), "
            "Delhi (30,290,936) and Shanghai (27,058,479)."
        ))],
        structuredContent={
            "sources": ["https://example.org/cities-data"],
            "table": [
                ["City", "Population"],
                ["Tokyo", "37400068"],
                ["Delhi", "30290936"],
                ["Shanghai", "27058479"],
            ],
        },
    )

Note that content repeats the facts. That is deliberate: the model writes its prose only from content.

How it is enforced

The annotation -> DataToolOutput ties the return value to a schema (a Pydantic ValidationModel) published as structured output. At startup, @registry.tool() inspects each function's return annotation. If it is not DataToolOutput, the server logs a warning and returns the function without registering it. The tool does not appear in tools/list, so no client can call it and the LLM never learns it exists.

This is stricter than MCP requires

The Model Context Protocol has no concept of a mandatory source. A perfectly conformant MCP tool can return an answer from nowhere. We deliberately narrow the standard: in this server, declaring where the data came from is a condition of being registered at all.

Our tools stay compatible: any MCP client can call them, it just gets a richer, source-carrying payload than the protocol guarantees.