Background
Every MCP tool currently maintains three parallel views of its response:
structured_content — typed Pydantic response model (machine-readable, drives Prefab UI binding)
- Prefab UI card — rich card builder for MCP-Apps-capable hosts (Claude Desktop)
- 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:
- Replace the
if request.format == \"json\" JSON-path body with content = response.model_dump_json(indent=2) and the markdown branch goes away
- Delete
_format_markdown helper(s)
- Drop the
format parameter from the tool signature + Request model
- 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:
- 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
- The MCP-Apps reference servers all emit JSON
content; matching the convention costs us less and lands us in the same place
- 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.
Background
Every MCP tool currently maintains three parallel views of its response:
structured_content— typed Pydantic response model (machine-readable, drives Prefab UI binding)content— hand-written_format_markdown/ inline string-builders for text-only hostsAdding 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_contentvs. drop it altogether — going with the simpler option.Proposal: tool
contentbecomesresponse.model_dump_json(indent=2)for every tool. Hand-written_format_markdownhelpers and inline string-builders all get deleted. Theformat: 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=...)setscontent=response.model_dump_json(), and the docstring on that helper explicitly notes: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) -> strhelper to keep markdown alive without per-tool duplication. Pivoting because:_format_markdownhelpersBoth 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
contentas text in transcripts. Markdown is more skimmable than JSON for humans reviewing what happened.Counter:
content, summarizes for the user. The user sees Claude's prose response, not the JSON.{\"sku\": \"X\", \"sales_price\": 299.99}as well as a markdown table. Better, even — no header/value ambiguity.jqandpython -m json.toolsolve the eyeball-grok problem. Indented JSON (model_dump_json(indent=2)) helps without needing them.structured_content, the visible-text-content audience shrinks.structured_contentis 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:cache_admin.pycustomers.pyitems.pyinventory.pymanufacturing_orders.pypurchase_orders.pysales_orders.pystock_transfers.pyreporting.pyApproach
Phase 1 — Pick the canonical JSON shape
Decide on
indent=2(yes, for readability) and whetherdefault=stris needed for timestamps. Codify in a thin helper if it's worth one — likely a single line intool_result_utils.pyor just inlined at each call site.Phase 2 — Migrate tools
One PR per tool family. For each tool:
if request.format == \"json\"JSON-path body withcontent = response.model_dump_json(indent=2)and the markdown branch goes away_format_markdownhelper(s)formatparameter from the tool signature +Requestmodel\"WIDGET-A\" in textkeep working (JSON contains the SKU too); table-structure assertions get dropped or rewritten againststructured_contentOrder: start with
get_variant_detailssince 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. Theformat=\"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 —
contentis auto-derived structurally.Out of scope
structured_content— that's the source of truth; nothing about its shape changes.structured_content+contentper spec.Subsumes / supersedes
prior_statemarkdown rendering for large entities. Subsumed entirely: there's no markdown to improve. The "summary inline + link to full snapshot" idea moves to a Prefab-UI design constraint instead.Affected umbrellas
Acceptance
model_dump_json(indent=2, default=str))contentreturnsresponse.model_dump_json(...); no_format_markdownhelpers remain inkatana_mcp_server/formatparameter dropped from every tool signature + Request modelHistory
The original framing of this issue proposed auto-generating markdown from
structured_contentinstead of dropping it. Reframed 2026-05-13 to drop entirely, on the basis that:content; matching the convention costs us less and lands us in the same placeDiscovered 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.