Skip to content

Commit bf3b1b3

Browse files
ochafikclaude
andcommitted
fix(example): implement graceful teardown per spec recommendation
Address review feedback: the host now waits for guest's teardown response before unmounting, following the spec: "Host SHOULD wait for a response before tearing down the resource (to prevent data loss)." Changes: - Add close button to tool call panels - Two-phase unmount: isDestroying state dims panel and disables events - sendResourceTeardown() completes before component removal - Non-app tool results close immediately 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent b16880d commit bf3b1b3

File tree

2 files changed

+107
-19
lines changed

2 files changed

+107
-19
lines changed

examples/basic-host/src/index.module.css

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,32 @@
9292
flex-direction: column;
9393
margin: 0;
9494
font-size: 1.5rem;
95+
position: relative;
9596

9697
.toolName {
9798
font-family: monospace;
9899
}
100+
101+
.closeButton {
102+
position: absolute;
103+
top: 0;
104+
right: 0;
105+
width: 1.5rem;
106+
height: 1.5rem;
107+
padding: 0;
108+
border: none;
109+
border-radius: 4px;
110+
background: #e0e0e0;
111+
font-size: 1.25rem;
112+
line-height: 1;
113+
color: #666;
114+
cursor: pointer;
115+
116+
&:hover {
117+
background: #d0d0d0;
118+
color: #333;
119+
}
120+
}
99121
}
100122
}
101123

examples/basic-host/src/index.tsx

Lines changed: 85 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,41 @@ import styles from "./index.module.css";
88
interface HostProps {
99
serversPromise: Promise<ServerInfo[]>;
1010
}
11+
12+
type ToolCallEntry = ToolCallInfo & { id: number };
13+
let nextToolCallId = 0;
14+
1115
function Host({ serversPromise }: HostProps) {
12-
const [toolCalls, setToolCalls] = useState<ToolCallInfo[]>([]);
16+
const [toolCalls, setToolCalls] = useState<ToolCallEntry[]>([]);
17+
const [destroyingIds, setDestroyingIds] = useState<Set<number>>(new Set());
18+
19+
const requestClose = (id: number) => {
20+
setDestroyingIds((s) => new Set(s).add(id));
21+
};
22+
23+
const completeClose = (id: number) => {
24+
setDestroyingIds((s) => {
25+
const next = new Set(s);
26+
next.delete(id);
27+
return next;
28+
});
29+
setToolCalls((calls) => calls.filter((c) => c.id !== id));
30+
};
1331

1432
return (
1533
<>
16-
{toolCalls.map((info, i) => (
17-
<ToolCallInfoPanel key={i} toolCallInfo={info} />
34+
{toolCalls.map((info) => (
35+
<ToolCallInfoPanel
36+
key={info.id}
37+
toolCallInfo={info}
38+
isDestroying={destroyingIds.has(info.id)}
39+
onRequestClose={() => requestClose(info.id)}
40+
onCloseComplete={() => completeClose(info.id)}
41+
/>
1842
))}
1943
<CallToolPanel
2044
serversPromise={serversPromise}
21-
addToolCall={(info) => setToolCalls([...toolCalls, info])}
45+
addToolCall={(info) => setToolCalls([...toolCalls, { ...info, id: nextToolCallId++ }])}
2246
/>
2347
</>
2448
);
@@ -135,23 +159,51 @@ function ServerSelect({ serversPromise, onSelect }: ServerSelectProps) {
135159

136160
interface ToolCallInfoPanelProps {
137161
toolCallInfo: ToolCallInfo;
162+
isDestroying?: boolean;
163+
onRequestClose?: () => void;
164+
onCloseComplete?: () => void;
138165
}
139-
function ToolCallInfoPanel({ toolCallInfo }: ToolCallInfoPanelProps) {
166+
function ToolCallInfoPanel({ toolCallInfo, isDestroying, onRequestClose, onCloseComplete }: ToolCallInfoPanelProps) {
167+
const isApp = hasAppHtml(toolCallInfo);
168+
169+
// For non-app tool calls, close immediately when isDestroying becomes true
170+
useEffect(() => {
171+
if (isDestroying && !isApp) {
172+
onCloseComplete?.();
173+
}
174+
}, [isDestroying, isApp, onCloseComplete]);
175+
140176
return (
141-
<div className={styles.toolCallInfoPanel}>
177+
<div
178+
className={styles.toolCallInfoPanel}
179+
style={isDestroying ? { opacity: 0.5, pointerEvents: "none" } : undefined}
180+
>
142181
<div className={styles.inputInfoPanel}>
143182
<h2>
144183
<span>{toolCallInfo.serverInfo.name}</span>
145184
<span className={styles.toolName}>{toolCallInfo.tool.name}</span>
185+
{onRequestClose && !isDestroying && (
186+
<button
187+
className={styles.closeButton}
188+
onClick={onRequestClose}
189+
title="Close"
190+
>
191+
×
192+
</button>
193+
)}
146194
</h2>
147195
<JsonBlock value={toolCallInfo.input} />
148196
</div>
149197
<div className={styles.outputInfoPanel}>
150198
<ErrorBoundary>
151199
<Suspense fallback="Loading...">
152200
{
153-
hasAppHtml(toolCallInfo)
154-
? <AppIFramePanel toolCallInfo={toolCallInfo} />
201+
isApp
202+
? <AppIFramePanel
203+
toolCallInfo={toolCallInfo}
204+
isDestroying={isDestroying}
205+
onTeardownComplete={onCloseComplete}
206+
/>
155207
: <ToolResultPanel toolCallInfo={toolCallInfo} />
156208
}
157209
</Suspense>
@@ -173,8 +225,10 @@ function JsonBlock({ value }: { value: object }) {
173225

174226
interface AppIFramePanelProps {
175227
toolCallInfo: Required<ToolCallInfo>;
228+
isDestroying?: boolean;
229+
onTeardownComplete?: () => void;
176230
}
177-
function AppIFramePanel({ toolCallInfo }: AppIFramePanelProps) {
231+
function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppIFramePanelProps) {
178232
const iframeRef = useRef<HTMLIFrameElement | null>(null);
179233
const appBridgeRef = useRef<ReturnType<typeof newAppBridge> | null>(null);
180234

@@ -191,18 +245,30 @@ function AppIFramePanel({ toolCallInfo }: AppIFramePanelProps) {
191245
initializeApp(iframe, appBridge, toolCallInfo);
192246
}
193247
});
194-
195-
// Cleanup: send teardown notification before unmounting
196-
return () => {
197-
if (appBridgeRef.current) {
198-
log.info("Sending teardown notification to MCP App");
199-
appBridgeRef.current.sendResourceTeardown({}).catch((err) => {
200-
log.warn("Teardown request failed (app may have already closed):", err);
201-
});
202-
}
203-
};
204248
}, [toolCallInfo]);
205249

250+
// Graceful teardown: wait for guest to respond before unmounting
251+
// This follows the spec: "Host SHOULD wait for a response before tearing
252+
// down the resource (to prevent data loss)."
253+
useEffect(() => {
254+
if (!isDestroying) return;
255+
256+
if (!appBridgeRef.current) {
257+
// Bridge not ready yet (e.g., user closed before iframe loaded)
258+
onTeardownComplete?.();
259+
return;
260+
}
261+
262+
log.info("Sending teardown notification to MCP App");
263+
appBridgeRef.current.sendResourceTeardown({})
264+
.catch((err) => {
265+
log.warn("Teardown request failed (app may have already closed):", err);
266+
})
267+
.finally(() => {
268+
onTeardownComplete?.();
269+
});
270+
}, [isDestroying, onTeardownComplete]);
271+
206272
return (
207273
<div className={styles.appIframePanel}>
208274
<iframe ref={iframeRef} />

0 commit comments

Comments
 (0)