Skip to content

Commit b6fb056

Browse files
dougborgclaude
andcommitted
fix(mcp): lead preview coaching with no-iframe fallback (#648)
The preview→apply coaching appended to every preview-mode write tool's description led with the iframe-happy path ("returns a Prefab card with Confirm/Cancel buttons. End your turn after the preview response.") and relegated the no-iframe fallback to a final paragraph. Agents in hosts that don't render MCP Apps iframes (Claude Code, plain CLI clients) followed the lead sentence, ended their turn, and waited for clicks the user could never make. Restructure both PREVIEW_APPLY_COACHING and PREVIEW_APPLY_DIRECT_COACHING so the no-iframe scenario is explicit and ordered first, and point the agent at the ``content`` channel JSON as the data source for the in-chat summary. Sync the parallel guidance in the help resource. Pin the new ordering with TestPreviewCoachingLeadsWithNoIframeFallback in test_prefab_ui.py: both coaching strings must (1) mention "does NOT render" before "DOES render", (2) reference the ``content`` channel, and (3) include the ``preview=False`` re-issue mechanic. Together with the existing test_tool_descriptions_and_annotations.py coverage of "Do NOT re-narrate" / "Apply: call" / "ui/update-model-context", every registered preview-button tool now has both the happy-path button contract and the no-iframe fallback contract enforced. Bundles the uv.lock sync to mcp v0.69.0 that landed on main but didn't update the workspace lockfile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 12062a5 commit b6fb056

4 files changed

Lines changed: 174 additions & 43 deletions

File tree

katana_mcp_server/src/katana_mcp/resources/help.py

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -102,22 +102,46 @@
102102
103103
### Agent guidance for preview→apply
104104
105-
After returning a preview, **do not re-narrate the card or ask for
106-
confirmation in chat** — the buttons handle that. End your turn and wait
107-
for the user.
108-
109-
When the user clicks Confirm on a preview card, the iframe sends a chat
110-
message of the form:
105+
The same preview card serves two host families, and the agent's
106+
end-of-turn behavior is different for each. The `content` channel of
107+
every preview response carries the response JSON, so the data is always
108+
available even if the card itself doesn't render.
109+
110+
**1. Hosts that do NOT render MCP Apps iframes** (Claude Code, plain CLI
111+
clients). The Prefab card is invisible — the user can't click anything.
112+
Summarize the planned change in chat from the `content` JSON (what's
113+
changing, key field values), ask the user to confirm, and on yes
114+
re-issue the call with `preview=False` yourself. Do **not** end your
115+
turn waiting for a button click; there is none. Common signals that
116+
this is the case: the user says they can't see the card, or you have no
117+
prior evidence iframes have rendered in this session.
118+
119+
**2. Hosts that DO render iframes** (Claude Desktop, Claude.ai, Cowork).
120+
The card has Confirm and Cancel buttons the user clicks. **Do not
121+
re-narrate the card or ask for confirmation in chat** — the buttons
122+
handle that. End your turn after the preview response. The Confirm-click
123+
behavior depends on which rail the tool is on:
124+
125+
*SendMessage rail (ADR-0015) — default for tools that don't opt into
126+
direct-apply.* The iframe sends a chat message of the form:
111127
112128
Apply: call <tool_name>(<arg>=<value>, ..., preview=False)
113129
114130
Recognize the `Apply:` prefix and re-issue the tool call **exactly as
115-
written**, with all inlined arguments preserved. The agent's tool-calling
116-
loop is the only path that lets the agent see the structured apply
117-
response — the iframe-initiated call (which the spec routes back to the
118-
iframe, not to the agent) was the wrong rail; see ADR-0015.
119-
120-
When the user clicks Cancel, the iframe sends:
131+
written**, with all inlined arguments preserved. Re-issuing through the
132+
agent's tool-calling loop is what lets the agent see the structured
133+
apply response on this rail; the iframe-initiated call alone routes its
134+
result back to the iframe, not to the agent.
135+
136+
*Direct-apply rail (ADR-0016) — used by `create_purchase_order`,
137+
`create_sales_order`, `create_manufacturing_order`, the unified
138+
`modify_<entity>` / `delete_<entity>` family, the `correct_*` family,
139+
and the stock-adjustment write tools.* The Confirm click fires the
140+
apply `tools/call` directly and the iframe pushes the structured result
141+
back to the agent's model context via `ui/update-model-context`. The
142+
agent sees the apply response on its next turn without re-issuing.
143+
144+
Cancel behavior is the same on both rails: the iframe sends
121145
122146
Cancel: do not apply <description>.
123147

katana_mcp_server/src/katana_mcp/tools/prefab_ui.py

Lines changed: 53 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -76,20 +76,35 @@ def _split_warnings(
7676
return blocks, regulars
7777

7878

79-
# Coaching text appended to every preview-mode write tool's description so
80-
# the agent (1) does not re-narrate or ask for chat-confirmation after a
81-
# preview returns (closes #544), and (2) knows to re-issue the call when it
82-
# sees an ``Apply: call ...`` SendMessage from a Confirm-button click. The
83-
# rationale lives in ADR-0015.
79+
# Coaching text appended to every preview-mode write tool's description.
80+
# The agent's host may or may not render MCP Apps iframes — lead with the
81+
# no-iframe path because it's the footgun: in iframe hosts the buttons
82+
# handle the UX with no agent help, but in non-iframe hosts an agent that
83+
# follows the iframe-happy-path coaching ends its turn waiting for clicks
84+
# that never come (see #648). Mention the iframe path second.
85+
#
86+
# The rationale for the chat-message rail lives in ADR-0015; the
87+
# Confirm-then-iframe-handles-everything UX in ADR-0016.
8488
PREVIEW_APPLY_COACHING = (
85-
"Preview→apply: when ``preview=True`` (default), returns a Prefab card "
86-
"with Confirm/Cancel buttons. Do NOT re-narrate the card or ask for "
87-
"confirmation in chat — the buttons handle that. End your turn after "
88-
"the preview response.\n\n"
89-
"If you receive a chat message starting with ``Apply: call <tool>(...)`` "
90-
"or ``Cancel: do not apply ...``, that is a button click from a previous "
91-
"preview. For Apply, re-issue the tool call exactly as written. For "
92-
"Cancel, acknowledge briefly without re-issuing."
89+
"Preview→apply pattern: when ``preview=True`` (default) the tool returns "
90+
"a Prefab card describing the planned change. The ``content`` channel of "
91+
"the tool result carries the response JSON, so you always have the data "
92+
"even if the card itself doesn't render. Handle both host scenarios:\n\n"
93+
"1. If the host does NOT render MCP Apps iframes (Claude Code, plain CLI "
94+
"clients, etc.) — common signal: the user says they can't see the card, "
95+
"or you have no other evidence the card was rendered — the Prefab card "
96+
"is invisible. Summarize the planned change in chat from the ``content`` "
97+
"JSON (what's changing, key field values), ask the user to confirm, then "
98+
"re-issue the call with ``preview=False`` yourself. Do NOT end your turn "
99+
"waiting for a button click — there is none.\n\n"
100+
"2. If the host DOES render iframes (Claude Desktop, Claude.ai, Cowork, "
101+
"etc.), the card has Confirm/Cancel buttons the user clicks. Do NOT "
102+
"re-narrate the card or ask for confirmation in chat — the buttons "
103+
"handle that. End your turn after the preview response. If you later "
104+
"receive a chat message starting with ``Apply: call <tool>(...)`` or "
105+
"``Cancel: do not apply ...``, that is a button click from a previous "
106+
"preview: for Apply re-issue the tool call exactly as written; for "
107+
"Cancel acknowledge briefly without re-issuing."
93108
)
94109

95110

@@ -98,24 +113,32 @@ def _split_warnings(
98113
# ``ui/update-model-context``). The agent does not re-issue the call — it
99114
# receives the apply result automatically on its next turn. See
100115
# ``docs/adr/0016-direct-apply-confirm-button-rail.md`` (forthcoming).
116+
#
117+
# Same no-iframe-first lead as PREVIEW_APPLY_COACHING (see #648).
101118
PREVIEW_APPLY_DIRECT_COACHING = (
102-
"Preview→apply: when ``preview=True`` (default) AND the host renders "
103-
"MCP Apps iframes (Claude Desktop, Claude.ai, Cowork, etc.), returns a "
104-
"Prefab card with Confirm/Cancel buttons. Do NOT re-narrate the card or "
105-
"ask for confirmation in chat — the buttons handle that. End your turn "
106-
"after the preview response.\n\n"
107-
"When the user clicks Confirm in the iframe, the iframe fires the apply "
108-
"call directly and morphs in place to a result card. The structured "
109-
"apply response (id, status, etc.) arrives in your context on your next "
110-
"turn via ``ui/update-model-context``. Treat it as you would any "
111-
"tool-call result — acknowledge completion, suggest next steps. Do NOT "
112-
"re-issue the call after the iframe already applied.\n\n"
113-
"Non-iframe-host fallback: if the host does not render Prefab cards "
114-
"(no iframe was shown to the user), there are no buttons. Ask the user "
115-
"for confirmation in chat, then re-issue with ``preview=False`` "
116-
"yourself.\n\n"
117-
"If you receive a chat message starting with ``Cancel: do not apply ...``, "
118-
"the user opted out — acknowledge briefly without re-issuing."
119+
"Preview→apply pattern: when ``preview=True`` (default) the tool returns "
120+
"a Prefab card describing the planned change. The ``content`` channel of "
121+
"the tool result carries the response JSON, so you always have the data "
122+
"even if the card itself doesn't render. Handle both host scenarios:\n\n"
123+
"1. If the host does NOT render MCP Apps iframes (Claude Code, plain CLI "
124+
"clients, etc.) — common signal: the user says they can't see the card, "
125+
"or you have no other evidence the card was rendered — the Prefab card "
126+
"is invisible. Summarize the planned change in chat from the ``content`` "
127+
"JSON (what's changing, key field values), ask the user to confirm, then "
128+
"re-issue the call with ``preview=False`` yourself. Do NOT end your turn "
129+
"waiting for a button click — there is none.\n\n"
130+
"2. If the host DOES render iframes (Claude Desktop, Claude.ai, Cowork, "
131+
"etc.), the card has Confirm/Cancel buttons the user clicks. When the "
132+
"user clicks Confirm in the iframe, the iframe fires the apply call "
133+
"directly and morphs in place to a result card. The structured apply "
134+
"response (id, status, etc.) arrives in your context on your next turn "
135+
"via ``ui/update-model-context``. Treat it as you would any tool-call "
136+
"result — acknowledge completion, suggest next steps. Do NOT re-narrate "
137+
"the preview card, do NOT ask for confirmation in chat (the buttons "
138+
"handle that), and Do NOT re-issue the call after the iframe already "
139+
"applied. End your turn after the preview response. If you receive a "
140+
"chat message starting with ``Cancel: do not apply ...``, the user "
141+
"opted out — acknowledge briefly without re-issuing."
119142
)
120143

121144

katana_mcp_server/tests/test_prefab_ui.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
import pytest
1515
from katana_mcp.tools.prefab_ui import (
16+
PREVIEW_APPLY_COACHING,
17+
PREVIEW_APPLY_DIRECT_COACHING,
1618
build_batch_recipe_update_ui,
1719
build_fulfill_preview_ui,
1820
build_fulfill_success_ui,
@@ -28,6 +30,7 @@
2830
build_search_results_ui,
2931
build_variant_details_ui,
3032
build_verification_ui,
33+
with_preview_coaching,
3134
)
3235
from prefab_ui.app import PrefabApp
3336
from pydantic import BaseModel
@@ -2256,3 +2259,84 @@ def collect_titles(o: Any) -> None:
22562259
assert any(t.endswith("Delete") for t in titles), (
22572260
f"delete_* result title must end with 'Delete'; got {titles!r}"
22582261
)
2262+
2263+
2264+
class TestPreviewCoachingLeadsWithNoIframeFallback:
2265+
"""Regression tests for #648 — the docstring coaching templates must
2266+
surface the non-iframe-host fallback prominently, not as a footnote.
2267+
Agents in Claude Code / plain CLI hosts can't click iframe buttons; the
2268+
previous coaching led with the iframe-happy path and agents silently
2269+
ended their turns waiting for clicks the user could not make.
2270+
"""
2271+
2272+
@pytest.mark.parametrize(
2273+
"coaching",
2274+
[PREVIEW_APPLY_COACHING, PREVIEW_APPLY_DIRECT_COACHING],
2275+
ids=["sendmessage-rail", "direct-apply-rail"],
2276+
)
2277+
def test_no_iframe_scenario_appears_before_iframe_scenario(
2278+
self, coaching: str
2279+
) -> None:
2280+
"""The no-iframe path must be discussed BEFORE the iframe path so
2281+
agents whose host doesn't render Prefab cards don't miss the
2282+
fallback instructions."""
2283+
# Numbered scenarios — `1.` introduces no-iframe, `2.` iframe.
2284+
no_iframe_idx = coaching.find("does NOT render")
2285+
iframe_idx = coaching.find("DOES render")
2286+
assert no_iframe_idx != -1, (
2287+
"coaching must explicitly call out the no-iframe path "
2288+
f"(searched for 'does NOT render'); got:\n{coaching}"
2289+
)
2290+
assert iframe_idx != -1, (
2291+
"coaching must also explain the iframe path "
2292+
f"(searched for 'DOES render'); got:\n{coaching}"
2293+
)
2294+
assert no_iframe_idx < iframe_idx, (
2295+
"no-iframe scenario must appear FIRST so agents read it before "
2296+
"the iframe-happy path; reversing the order is exactly the "
2297+
"footgun #648 fixed."
2298+
)
2299+
2300+
@pytest.mark.parametrize(
2301+
"coaching",
2302+
[PREVIEW_APPLY_COACHING, PREVIEW_APPLY_DIRECT_COACHING],
2303+
ids=["sendmessage-rail", "direct-apply-rail"],
2304+
)
2305+
def test_mentions_content_channel_as_data_source(self, coaching: str) -> None:
2306+
"""The no-iframe fallback path tells the agent to summarize from the
2307+
``content`` channel — make sure the coaching points there explicitly
2308+
so agents don't claim "I don't have enough data" instead of reading
2309+
the JSON they were just handed."""
2310+
assert "``content``" in coaching, (
2311+
"coaching must direct the agent to the ``content`` channel for "
2312+
"the no-iframe summarize-then-confirm path; otherwise agents "
2313+
"may not realize the response data is already in context."
2314+
)
2315+
2316+
@pytest.mark.parametrize(
2317+
"coaching",
2318+
[PREVIEW_APPLY_COACHING, PREVIEW_APPLY_DIRECT_COACHING],
2319+
ids=["sendmessage-rail", "direct-apply-rail"],
2320+
)
2321+
def test_no_iframe_path_says_re_issue_with_preview_false(
2322+
self, coaching: str
2323+
) -> None:
2324+
"""The no-iframe fallback must spell out the apply mechanic
2325+
(``preview=False``) so the agent isn't left guessing how to apply."""
2326+
assert "preview=False" in coaching, (
2327+
"the no-iframe fallback must tell the agent to re-issue with "
2328+
"``preview=False``"
2329+
)
2330+
2331+
def test_with_preview_coaching_appends_to_existing_docstring(self) -> None:
2332+
"""``with_preview_coaching`` should preserve the function's own
2333+
docstring AND append the new lead-with-no-iframe block — verifies
2334+
the rewrite didn't break the existing concatenation contract.
2335+
"""
2336+
2337+
def fn() -> None:
2338+
"""My tool's own purpose."""
2339+
2340+
result = with_preview_coaching(fn)
2341+
assert result.startswith("My tool's own purpose.")
2342+
assert "does NOT render" in result

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)