Skip to content

Tool Results & Content Routing

When your MCP server returns a tool result, you control what the LLM sees and what the UI displays using MCP content blocks and audience annotations. This matters for token efficiency — binary data like images and PDFs should go to the UI, not the LLM context.

MCP tool results contain a content array of typed blocks:

TypeUse caseExample
TextContentStructured data, summaries, status messages{"type": "text", "text": "Created 3 records"}
ImageContentRendered previews, charts, screenshots{"type": "image", "data": "iVBOR...", "mimeType": "image/png"}
AudioContentVoice responses, audio clips{"type": "audio", "data": "...", "mimeType": "audio/wav"}

Every content block supports an optional annotations field from the MCP spec.

The audience field controls who sees a content block:

ValueWho sees itWhen to use
["assistant"]LLM onlyData the model needs but the user doesn’t (internal state, intermediate results)
["user"]UI onlyBinary data, rendered previews, downloadable files — anything the LLM can’t meaningfully interpret
["user", "assistant"]BothContent useful to both (default behavior)
(absent)BothBackward compatible — treated the same as ["user", "assistant"]

A document preview tool that returns rendered PNG pages:

from mcp.types import Annotations, ImageContent, TextContent
USER_ONLY = Annotations(audience=["user"])
@mcp.tool()
async def preview() -> list[TextContent | ImageContent]:
"""Render document to preview images."""
pages = compile_document()
blocks: list[TextContent | ImageContent] = [
# Summary for the LLM — lightweight confirmation
TextContent(type="text", text=f"Preview rendered ({len(pages)} pages)"),
]
# Images for the UI only — base64 data stays out of LLM context
for i, png_bytes in enumerate(pages):
blocks.append(ImageContent(
type="image",
data=base64.b64encode(png_bytes).decode(),
mimeType="image/png",
annotations=USER_ONLY,
))
return blocks

Without audience annotations, the base64 image data (~12,000+ tokens per page) goes into the LLM conversation history and persists for every subsequent tool call iteration. With annotations, the LLM sees only "Preview rendered (3 pages)" (~6 tokens).

A PDF export tool where the binary data is for download, not the LLM:

@mcp.tool()
async def export_pdf() -> list[TextContent]:
result = generate_pdf()
blocks = [
TextContent(type="text", text=f"Exported {result.filename} ({result.page_count} pages)"),
]
if result.pdf_base64:
blocks.append(TextContent(
type="text",
text=result.pdf_base64,
annotations=USER_ONLY,
))
return blocks

When your app’s UI calls a tool via callTool(), the full content array (including user-only blocks) is available on result.content:

const result = await callTool("preview", {});
// result.data = "Preview rendered (3 pages)" (parsed from first text block)
// result.content = full array including ImageContent blocks
const images = (result.content ?? [])
.filter((block) => block.type === "image")
.map((block) => block.data); // base64 strings
ScenarioAnnotationWhy
Rendered images (previews, charts)audience: ["user"]LLM can’t interpret base64; images are for visual display
PDF/file downloadsaudience: ["user"]Binary data for the browser, not the model
Status messages(none)Both LLM and user benefit from “3 records created”
Structured data the LLM will act on(none)The LLM needs this to make decisions
Internal state for debuggingaudience: ["user"]Useful in the UI console but wastes LLM context

Every content block in a tool result stays in the conversation history for all subsequent LLM iterations within the same turn. A single un-annotated image adds ~12,000 tokens per iteration. Over 10 iterations, that’s 120,000 tokens of context consumed by data the LLM can’t use.

Use audience: ["user"] for any content block the LLM doesn’t need to reason about.