From 70b393a7e6ce13c6ea744600bee21b1ef959dc2f Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 11 Dec 2025 20:31:17 +0100 Subject: [PATCH] feat: implement ui/notifications/tool-cancelled + add missing App.onteardown (#120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: implement ui/notifications/tool-cancelled throughout SDK Wire the tool-cancelled notification that hosts MUST send when tool execution is cancelled (user action, timeout, classifier intervention, etc.): - Add McpUiToolCancelledNotification type in spec.types.ts - Generate Zod schema via ts-to-zod - Add AppBridge.sendToolCancelled() method for hosts - Add App.ontoolcancelled setter for guest UIs - Export type and schema from types.ts - Add tests for notification with/without reason 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * feat: add App.onteardown handler for graceful shutdown Wire the guest UI side of ui/resource-teardown request: - Add onteardown setter in App class for guest UIs to handle teardown - Allow ui/resource-teardown in assertRequestHandlerCapability - Import McpUiResourceTeardownRequest/Result types - Add tests for sync and async cleanup handlers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * feat(example): wire tool-cancelled and teardown in basic-host Update basic-host example to properly use the new notifications: - Send sendToolCancelled when tool call promise rejects - Send sendResourceTeardown when React component unmounts - Store appBridge ref for cleanup access 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * 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 * feat(examples): add onteardown handlers to demo apps Demonstrate graceful shutdown with simulated 500ms cleanup work. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- examples/basic-host/src/implementation.ts | 18 +++- examples/basic-host/src/index.module.css | 22 +++++ examples/basic-host/src/index.tsx | 96 +++++++++++++++++-- examples/basic-server-react/src/mcp-app.tsx | 8 +- .../basic-server-vanillajs/src/mcp-app.ts | 6 ++ src/app-bridge.test.ts | 58 +++++++++++ src/app-bridge.ts | 38 ++++++++ src/app.ts | 83 ++++++++++++++++ src/generated/schema.json | 22 +++++ src/generated/schema.test.ts | 10 ++ src/generated/schema.ts | 18 ++++ src/spec.types.ts | 13 +++ src/types.ts | 2 + 13 files changed, 379 insertions(+), 15 deletions(-) diff --git a/examples/basic-host/src/implementation.ts b/examples/basic-host/src/implementation.ts index d1d1bcc..9ab5759 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 c162aa8..0c4a8b0 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 37eac43..1288a34 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 (