@@ -8,17 +8,41 @@ import styles from "./index.module.css";
88interface HostProps {
99 serversPromise : Promise < ServerInfo [ ] > ;
1010}
11+
12+ type ToolCallEntry = ToolCallInfo & { id : number } ;
13+ let nextToolCallId = 0 ;
14+
1115function 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
136160interface 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,9 +225,12 @@ function JsonBlock({ value }: { value: object }) {
173225
174226interface 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 ) ;
233+ const appBridgeRef = useRef < ReturnType < typeof newAppBridge > | null > ( null ) ;
179234
180235 useEffect ( ( ) => {
181236 const iframe = iframeRef . current ! ;
@@ -186,11 +241,34 @@ function AppIFramePanel({ toolCallInfo }: AppIFramePanelProps) {
186241 // `toolCallInfo`.
187242 if ( firstTime ) {
188243 const appBridge = newAppBridge ( toolCallInfo . serverInfo , toolCallInfo , iframe ) ;
244+ appBridgeRef . current = appBridge ;
189245 initializeApp ( iframe , appBridge , toolCallInfo ) ;
190246 }
191247 } ) ;
192248 } , [ toolCallInfo ] ) ;
193249
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+
194272 return (
195273 < div className = { styles . appIframePanel } >
196274 < iframe ref = { iframeRef } />
0 commit comments