Skip to content

design(mcp): drop hand-written markdown formatters; content becomes structured_content JSON (umbrella) #567

@dougborg

Description

@dougborg

Background

Every MCP tool currently maintains three parallel views of its response:

  1. structured_content — typed Pydantic response model (machine-readable, drives Prefab UI binding)
  2. Prefab UI card — rich card builder for MCP-Apps-capable hosts (Claude Desktop)
  3. Markdown content — hand-written _format_markdown / inline string-builders for text-only hosts

Adding a field requires updating all three. Forgetting any one silently degrades a code path. This is exactly what bit us in #565 (variant card redesign added 4 fields to VariantDetailsResponse + Prefab card; multi-variant markdown table wasn't updated; agents batching SKUs see the old 5-column table without the new fields).

Decision: drop markdown entirely

After exploring two alternatives — auto-generate markdown from structured_content vs. drop it altogether — going with the simpler option.

Proposal: tool content becomes response.model_dump_json(indent=2) for every tool. Hand-written _format_markdown helpers and inline string-builders all get deleted. The format: Literal[\"markdown\", \"json\"] parameter drops from every tool because there's only one mode left.

UI-emitting tools already do this — make_tool_result(response, ui=...) sets content=response.model_dump_json(), and the docstring on that helper explicitly notes:

This matches the data-heavy reference servers in modelcontextprotocol/ext-apps/examples (customer-segmentation, system-monitor, etc.) — none use formatted markdown for content.

We extend that convention to every tool, not just UI-emitting ones.

Why drop entirely (not auto-generate)

Originally this issue proposed an auto-generated to_markdown(response: BaseModel) -> str helper to keep markdown alive without per-tool duplication. Pivoting because:

Factor Auto-generate Drop entirely
Drift surface None (one helper) None (no helper)
LOC added ~200 (helper) + ~22 (migrations) 0 helper + ~22 migrations
LOC deleted ~22 _format_markdown helpers ~22 helpers + format param + branch
Edge cases long text, nested lists, link rendering, table widths, optional-None display none
Maintenance helper grows with new response shapes nothing to maintain
Per-tool migration mechanical mechanical

Both approaches eliminate the structural drift bug. Dropping is strictly simpler and matches the MCP-Apps reference convention.

Pushback on "but text-only hosts read content as JSON now"

The concern: Claude Code, ChatGPT, terminal MCP clients show content as text in transcripts. Markdown is more skimmable than JSON for humans reviewing what happened.

Counter:

  • Users don't typically read raw tool output. Claude (the LLM) reads content, summarizes for the user. The user sees Claude's prose response, not the JSON.
  • LLMs handle JSON fine. Modern Claude reads {\"sku\": \"X\", \"sales_price\": 299.99} as well as a markdown table. Better, even — no header/value ambiguity.
  • Debugging tools exist. jq and python -m json.tool solve the eyeball-grok problem. Indented JSON (model_dump_json(indent=2)) helps without needing them.
  • Forward trajectory. As more hosts adopt MCP-Apps and render structured_content, the visible-text-content audience shrinks.
  • structured_content is unaffected. Programmatic consumers (Prefab UI, future MCP host renderers) keep getting the typed payload, just like today.

If a specific user surfaces a real workflow regression, we can revisit. The hypothesis is that the simplification savings dwarf any actual UX cost.

Scope

~22 tools with format: Literal[\"markdown\", \"json\"] parameters today. Same surface as the prior auto-generation proposal:

Module Tools
cache_admin.py rebuild_cache
customers.py search_customers, get_customer
items.py search_items, get_item, get_variant_details
inventory.py check_inventory, list_low_stock_items
manufacturing_orders.py list_manufacturing_orders, get_manufacturing_order, modify_*, get_manufacturing_order_recipe
purchase_orders.py list_purchase_orders, get_purchase_order, modify_*, receive_purchase_order
sales_orders.py list_sales_orders, get_sales_order, modify_*
stock_transfers.py list_stock_transfers, get_stock_transfer, modify_*
reporting.py top_selling_variants, sales_summary, inventory_velocity

Approach

Phase 1 — Pick the canonical JSON shape

Decide on indent=2 (yes, for readability) and whether default=str is needed for timestamps. Codify in a thin helper if it's worth one — likely a single line in tool_result_utils.py or just inlined at each call site.

Phase 2 — Migrate tools

One PR per tool family. For each tool:

  1. Replace the if request.format == \"json\" JSON-path body with content = response.model_dump_json(indent=2) and the markdown branch goes away
  2. Delete _format_markdown helper(s)
  3. Drop the format parameter from the tool signature + Request model
  4. Update existing tests — assertions like \"WIDGET-A\" in text keep working (JSON contains the SKU too); table-structure assertions get dropped or rewritten against structured_content

Order: start with get_variant_details since it drove the original report; closes #565 structurally. Then proceed by module size.

Phase 3 — Backward-compatibility window

The format=\"json\" callers stop passing it explicitly. The format=\"markdown\" callers get JSON instead. Document the breaking change in the next MCP server major.

Phase 4 — Card-redesign sub-issues become smaller

The 9 card sub-issues from #537 (#549-#557, #548 already shipped) currently imply touching both Prefab card AND markdown for each entity. After this lands, they reduce to Prefab-card-only — content is auto-derived structurally.

Out of scope

  • Removing the Prefab card layer — Prefab UI stays for rich rendering; this issue is purely the markdown content layer.
  • Changing what's in structured_content — that's the source of truth; nothing about its shape changes.
  • Multi-host compatibility audits — assumed all conformant MCP hosts handle structured_content + content per spec.

Subsumes / supersedes

Affected umbrellas

Acceptance

History

The original framing of this issue proposed auto-generating markdown from structured_content instead of dropping it. Reframed 2026-05-13 to drop entirely, on the basis that:

  1. The auto-generation helper introduces a maintenance surface (long-text rendering, link conventions, table widths, optional-None display) that the JSON path doesn't have
  2. The MCP-Apps reference servers all emit JSON content; matching the convention costs us less and lands us in the same place
  3. Users reading raw tool output is a minority case; Claude's summary is what they actually see

Discovered in

PR #563 review by Copilot (2026-05-05) — surfaced the variant-card markdown drift; design discussion identified the underlying three-sources-of-truth problem.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions