diff --git a/examples/basic-host/src/implementation.ts b/examples/basic-host/src/implementation.ts index d1d1bcc6b..9ab575911 100644 --- a/examples/basic-host/src/implementation.ts +++ b/examples/basic-host/src/implementation.ts @@ -186,11 +186,19 @@ export async function initializeApp( log.info("Sending tool call input to MCP App:", input); appBridge.sendToolInput({ arguments: input }); - // Schedule tool call result to be sent to MCP App - resultPromise.then((result) => { - log.info("Sending tool call result to MCP App:", result); - appBridge.sendToolResult(result); - }); + // Schedule tool call result (or cancellation) to be sent to MCP App + resultPromise.then( + (result) => { + log.info("Sending tool call result to MCP App:", result); + appBridge.sendToolResult(result); + }, + (error) => { + log.error("Tool call failed, sending cancellation to MCP App:", error); + appBridge.sendToolCancelled({ + reason: error instanceof Error ? error.message : String(error), + }); + }, + ); } /** diff --git a/examples/basic-host/src/index.module.css b/examples/basic-host/src/index.module.css index c162aa8c8..0c4a8b0ae 100644 --- a/examples/basic-host/src/index.module.css +++ b/examples/basic-host/src/index.module.css @@ -92,10 +92,32 @@ flex-direction: column; margin: 0; font-size: 1.5rem; + position: relative; .toolName { font-family: monospace; } + + .closeButton { + position: absolute; + top: 0; + right: 0; + width: 1.5rem; + height: 1.5rem; + padding: 0; + border: none; + border-radius: 4px; + background: #e0e0e0; + font-size: 1.25rem; + line-height: 1; + color: #666; + cursor: pointer; + + &:hover { + background: #d0d0d0; + color: #333; + } + } } } diff --git a/examples/basic-host/src/index.tsx b/examples/basic-host/src/index.tsx index 37eac43fc..1288a340d 100644 --- a/examples/basic-host/src/index.tsx +++ b/examples/basic-host/src/index.tsx @@ -8,17 +8,41 @@ import styles from "./index.module.css"; interface HostProps { serversPromise: Promise; } + +type ToolCallEntry = ToolCallInfo & { id: number }; +let nextToolCallId = 0; + function Host({ serversPromise }: HostProps) { - const [toolCalls, setToolCalls] = useState([]); + const [toolCalls, setToolCalls] = useState([]); + const [destroyingIds, setDestroyingIds] = useState>(new Set()); + + const requestClose = (id: number) => { + setDestroyingIds((s) => new Set(s).add(id)); + }; + + const completeClose = (id: number) => { + setDestroyingIds((s) => { + const next = new Set(s); + next.delete(id); + return next; + }); + setToolCalls((calls) => calls.filter((c) => c.id !== id)); + }; return ( <> - {toolCalls.map((info, i) => ( - + {toolCalls.map((info) => ( + requestClose(info.id)} + onCloseComplete={() => completeClose(info.id)} + /> ))} setToolCalls([...toolCalls, info])} + addToolCall={(info) => setToolCalls([...toolCalls, { ...info, id: nextToolCallId++ }])} /> ); @@ -135,14 +159,38 @@ function ServerSelect({ serversPromise, onSelect }: ServerSelectProps) { interface ToolCallInfoPanelProps { toolCallInfo: ToolCallInfo; + isDestroying?: boolean; + onRequestClose?: () => void; + onCloseComplete?: () => void; } -function ToolCallInfoPanel({ toolCallInfo }: ToolCallInfoPanelProps) { +function ToolCallInfoPanel({ toolCallInfo, isDestroying, onRequestClose, onCloseComplete }: ToolCallInfoPanelProps) { + const isApp = hasAppHtml(toolCallInfo); + + // For non-app tool calls, close immediately when isDestroying becomes true + useEffect(() => { + if (isDestroying && !isApp) { + onCloseComplete?.(); + } + }, [isDestroying, isApp, onCloseComplete]); + return ( -
+

{toolCallInfo.serverInfo.name} {toolCallInfo.tool.name} + {onRequestClose && !isDestroying && ( + + )}

@@ -150,8 +198,12 @@ function ToolCallInfoPanel({ toolCallInfo }: ToolCallInfoPanelProps) { { - hasAppHtml(toolCallInfo) - ? + isApp + ? : } @@ -173,9 +225,12 @@ function JsonBlock({ value }: { value: object }) { interface AppIFramePanelProps { toolCallInfo: Required; + isDestroying?: boolean; + onTeardownComplete?: () => void; } -function AppIFramePanel({ toolCallInfo }: AppIFramePanelProps) { +function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppIFramePanelProps) { const iframeRef = useRef(null); + const appBridgeRef = useRef | null>(null); useEffect(() => { const iframe = iframeRef.current!; @@ -186,11 +241,34 @@ function AppIFramePanel({ toolCallInfo }: AppIFramePanelProps) { // `toolCallInfo`. if (firstTime) { const appBridge = newAppBridge(toolCallInfo.serverInfo, iframe); + appBridgeRef.current = appBridge; initializeApp(iframe, appBridge, toolCallInfo); } }); }, [toolCallInfo]); + // Graceful teardown: wait for guest to respond before unmounting + // This follows the spec: "Host SHOULD wait for a response before tearing + // down the resource (to prevent data loss)." + useEffect(() => { + if (!isDestroying) return; + + if (!appBridgeRef.current) { + // Bridge not ready yet (e.g., user closed before iframe loaded) + onTeardownComplete?.(); + return; + } + + log.info("Sending teardown notification to MCP App"); + appBridgeRef.current.sendResourceTeardown({}) + .catch((err) => { + log.warn("Teardown request failed (app may have already closed):", err); + }) + .finally(() => { + onTeardownComplete?.(); + }); + }, [isDestroying, onTeardownComplete]); + return (