@@ -29,17 +29,41 @@ function getToolDefaults(tool: Tool | undefined): string {
2929interface HostProps {
3030 serversPromise : Promise < ServerInfo [ ] > ;
3131}
32+
33+ type ToolCallEntry = ToolCallInfo & { id : number } ;
34+ let nextToolCallId = 0 ;
35+
3236function Host ( { serversPromise } : HostProps ) {
33- const [ toolCalls , setToolCalls ] = useState < ToolCallInfo [ ] > ( [ ] ) ;
37+ const [ toolCalls , setToolCalls ] = useState < ToolCallEntry [ ] > ( [ ] ) ;
38+ const [ destroyingIds , setDestroyingIds ] = useState < Set < number > > ( new Set ( ) ) ;
39+
40+ const requestClose = ( id : number ) => {
41+ setDestroyingIds ( ( s ) => new Set ( s ) . add ( id ) ) ;
42+ } ;
43+
44+ const completeClose = ( id : number ) => {
45+ setDestroyingIds ( ( s ) => {
46+ const next = new Set ( s ) ;
47+ next . delete ( id ) ;
48+ return next ;
49+ } ) ;
50+ setToolCalls ( ( calls ) => calls . filter ( ( c ) => c . id !== id ) ) ;
51+ } ;
3452
3553 return (
3654 < >
37- { toolCalls . map ( ( info , i ) => (
38- < ToolCallInfoPanel key = { i } toolCallInfo = { info } />
55+ { toolCalls . map ( ( info ) => (
56+ < ToolCallInfoPanel
57+ key = { info . id }
58+ toolCallInfo = { info }
59+ isDestroying = { destroyingIds . has ( info . id ) }
60+ onRequestClose = { ( ) => requestClose ( info . id ) }
61+ onCloseComplete = { ( ) => completeClose ( info . id ) }
62+ />
3963 ) ) }
4064 < CallToolPanel
4165 serversPromise = { serversPromise }
42- addToolCall = { ( info ) => setToolCalls ( [ ...toolCalls , info ] ) }
66+ addToolCall = { ( info ) => setToolCalls ( [ ...toolCalls , { ... info , id : nextToolCallId ++ } ] ) }
4367 />
4468 </ >
4569 ) ;
@@ -164,23 +188,51 @@ function ServerSelect({ serversPromise, onSelect }: ServerSelectProps) {
164188
165189interface ToolCallInfoPanelProps {
166190 toolCallInfo : ToolCallInfo ;
191+ isDestroying ?: boolean ;
192+ onRequestClose ?: ( ) => void ;
193+ onCloseComplete ?: ( ) => void ;
167194}
168- function ToolCallInfoPanel ( { toolCallInfo } : ToolCallInfoPanelProps ) {
195+ function ToolCallInfoPanel ( { toolCallInfo, isDestroying, onRequestClose, onCloseComplete } : ToolCallInfoPanelProps ) {
196+ const isApp = hasAppHtml ( toolCallInfo ) ;
197+
198+ // For non-app tool calls, close immediately when isDestroying becomes true
199+ useEffect ( ( ) => {
200+ if ( isDestroying && ! isApp ) {
201+ onCloseComplete ?.( ) ;
202+ }
203+ } , [ isDestroying , isApp , onCloseComplete ] ) ;
204+
169205 return (
170- < div className = { styles . toolCallInfoPanel } >
206+ < div
207+ className = { styles . toolCallInfoPanel }
208+ style = { isDestroying ? { opacity : 0.5 , pointerEvents : "none" } : undefined }
209+ >
171210 < div className = { styles . inputInfoPanel } >
172211 < h2 >
173212 < span > { toolCallInfo . serverInfo . name } </ span >
174213 < span className = { styles . toolName } > { toolCallInfo . tool . name } </ span >
214+ { onRequestClose && ! isDestroying && (
215+ < button
216+ className = { styles . closeButton }
217+ onClick = { onRequestClose }
218+ title = "Close"
219+ >
220+ ×
221+ </ button >
222+ ) }
175223 </ h2 >
176224 < JsonBlock value = { toolCallInfo . input } />
177225 </ div >
178226 < div className = { styles . outputInfoPanel } >
179227 < ErrorBoundary >
180228 < Suspense fallback = "Loading..." >
181229 {
182- hasAppHtml ( toolCallInfo )
183- ? < AppIFramePanel toolCallInfo = { toolCallInfo } />
230+ isApp
231+ ? < AppIFramePanel
232+ toolCallInfo = { toolCallInfo }
233+ isDestroying = { isDestroying }
234+ onTeardownComplete = { onCloseComplete }
235+ />
184236 : < ToolResultPanel toolCallInfo = { toolCallInfo } />
185237 }
186238 </ Suspense >
@@ -202,9 +254,12 @@ function JsonBlock({ value }: { value: object }) {
202254
203255interface AppIFramePanelProps {
204256 toolCallInfo : Required < ToolCallInfo > ;
257+ isDestroying ?: boolean ;
258+ onTeardownComplete ?: ( ) => void ;
205259}
206- function AppIFramePanel ( { toolCallInfo } : AppIFramePanelProps ) {
260+ function AppIFramePanel ( { toolCallInfo, isDestroying , onTeardownComplete } : AppIFramePanelProps ) {
207261 const iframeRef = useRef < HTMLIFrameElement | null > ( null ) ;
262+ const appBridgeRef = useRef < ReturnType < typeof newAppBridge > | null > ( null ) ;
208263
209264 useEffect ( ( ) => {
210265 const iframe = iframeRef . current ! ;
@@ -215,11 +270,34 @@ function AppIFramePanel({ toolCallInfo }: AppIFramePanelProps) {
215270 // `toolCallInfo`.
216271 if ( firstTime ) {
217272 const appBridge = newAppBridge ( toolCallInfo . serverInfo , iframe ) ;
273+ appBridgeRef . current = appBridge ;
218274 initializeApp ( iframe , appBridge , toolCallInfo ) ;
219275 }
220276 } ) ;
221277 } , [ toolCallInfo ] ) ;
222278
279+ // Graceful teardown: wait for guest to respond before unmounting
280+ // This follows the spec: "Host SHOULD wait for a response before tearing
281+ // down the resource (to prevent data loss)."
282+ useEffect ( ( ) => {
283+ if ( ! isDestroying ) return ;
284+
285+ if ( ! appBridgeRef . current ) {
286+ // Bridge not ready yet (e.g., user closed before iframe loaded)
287+ onTeardownComplete ?.( ) ;
288+ return ;
289+ }
290+
291+ log . info ( "Sending teardown notification to MCP App" ) ;
292+ appBridgeRef . current . sendResourceTeardown ( { } )
293+ . catch ( ( err ) => {
294+ log . warn ( "Teardown request failed (app may have already closed):" , err ) ;
295+ } )
296+ . finally ( ( ) => {
297+ onTeardownComplete ?.( ) ;
298+ } ) ;
299+ } , [ isDestroying , onTeardownComplete ] ) ;
300+
223301 return (
224302 < div className = { styles . appIframePanel } >
225303 < iframe ref = { iframeRef } />
0 commit comments