From 123d6747bd7aec3adb740f665c3e22369312959e Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 10 Dec 2025 03:20:43 +0100 Subject: [PATCH 1/5] feat: implement ui/notifications/tool-cancelled throughout SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app-bridge.test.ts | 30 ++++++++++++++++++++++++++++ src/app-bridge.ts | 38 ++++++++++++++++++++++++++++++++++++ src/app.ts | 37 +++++++++++++++++++++++++++++++++++ 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 ++ 8 files changed, 170 insertions(+) diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index 0d9e9e5b..b31e1a43 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -140,6 +140,36 @@ describe("App <-> AppBridge integration", () => { }); }); + it("sendToolCancelled triggers app.ontoolcancelled", async () => { + const receivedCancellations: unknown[] = []; + app.ontoolcancelled = (params) => { + receivedCancellations.push(params); + }; + + await app.connect(appTransport); + await bridge.sendToolCancelled({ + reason: "User cancelled the operation", + }); + + expect(receivedCancellations).toHaveLength(1); + expect(receivedCancellations[0]).toEqual({ + reason: "User cancelled the operation", + }); + }); + + it("sendToolCancelled works without reason", async () => { + const receivedCancellations: unknown[] = []; + app.ontoolcancelled = (params) => { + receivedCancellations.push(params); + }; + + await app.connect(appTransport); + await bridge.sendToolCancelled({}); + + expect(receivedCancellations).toHaveLength(1); + expect(receivedCancellations[0]).toEqual({}); + }); + it("setHostContext triggers app.onhostcontextchanged", async () => { const receivedContexts: unknown[] = []; app.onhostcontextchanged = (params) => { diff --git a/src/app-bridge.ts b/src/app-bridge.ts index c4b2bdbd..c8e3b304 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -34,6 +34,7 @@ import { import { type McpUiSandboxResourceReadyNotification, type McpUiSizeChangedNotification, + type McpUiToolCancelledNotification, type McpUiToolInputNotification, type McpUiToolInputPartialNotification, type McpUiToolResultNotification, @@ -737,6 +738,43 @@ export class AppBridge extends Protocol { }); } + /** + * Notify the Guest UI that tool execution was cancelled. + * + * The host MUST send this notification if tool execution was cancelled for any + * reason, including user action, sampling error, classifier intervention, or + * any other interruption. This allows the Guest UI to update its state and + * display appropriate feedback to the user. + * + * @param params - Optional cancellation details: + * - `reason`: Human-readable explanation for why the tool was cancelled + * + * @example User-initiated cancellation + * ```typescript + * // User clicked "Cancel" button + * bridge.sendToolCancelled({ reason: "User cancelled the operation" }); + * ``` + * + * @example System-level cancellation + * ```typescript + * // Sampling error or timeout + * bridge.sendToolCancelled({ reason: "Request timeout after 30 seconds" }); + * + * // Classifier intervention + * bridge.sendToolCancelled({ reason: "Content policy violation detected" }); + * ``` + * + * @see {@link McpUiToolCancelledNotification} for the notification type + * @see {@link sendToolResult} for sending successful results + * @see {@link sendToolInput} for sending tool arguments + */ + sendToolCancelled(params: McpUiToolCancelledNotification["params"]) { + return this.notification({ + method: "ui/notifications/tool-cancelled", + params, + }); + } + /** * Send HTML resource to the sandbox proxy for secure loading. * diff --git a/src/app.ts b/src/app.ts index 6813becb..2cd68b46 100644 --- a/src/app.ts +++ b/src/app.ts @@ -32,6 +32,8 @@ import { McpUiOpenLinkRequest, McpUiOpenLinkResultSchema, McpUiSizeChangedNotification, + McpUiToolCancelledNotification, + McpUiToolCancelledNotificationSchema, McpUiToolInputNotification, McpUiToolInputNotificationSchema, McpUiToolInputPartialNotification, @@ -388,6 +390,41 @@ export class App extends Protocol { ); } + /** + * Convenience handler for receiving tool cancellation notifications from the host. + * + * Set this property to register a handler that will be called when the host + * notifies that tool execution was cancelled. This can occur for various reasons + * including user action, sampling error, classifier intervention, or other + * interruptions. Apps should update their state and display appropriate feedback. + * + * This setter is a convenience wrapper around `setNotificationHandler()` that + * automatically handles the notification schema and extracts the params for you. + * + * Register handlers before calling {@link connect} to avoid missing notifications. + * + * @param callback - Function called when tool execution is cancelled + * + * @example Handle tool cancellation + * ```typescript + * app.ontoolcancelled = (params) => { + * console.log("Tool cancelled:", params.reason); + * showCancelledMessage(params.reason ?? "Operation was cancelled"); + * }; + * ``` + * + * @see {@link setNotificationHandler} for the underlying method + * @see {@link McpUiToolCancelledNotification} for the notification structure + * @see {@link ontoolresult} for successful tool completion + */ + set ontoolcancelled( + callback: (params: McpUiToolCancelledNotification["params"]) => void, + ) { + this.setNotificationHandler(McpUiToolCancelledNotificationSchema, (n) => + callback(n.params), + ); + } + /** * Convenience handler for host context changes (theme, viewport, locale, etc.). * diff --git a/src/generated/schema.json b/src/generated/schema.json index b51e23bc..f3c1b180 100644 --- a/src/generated/schema.json +++ b/src/generated/schema.json @@ -1732,6 +1732,28 @@ } ] }, + "McpUiToolCancelledNotification": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "method": { + "type": "string", + "const": "ui/notifications/tool-cancelled" + }, + "params": { + "type": "object", + "properties": { + "reason": { + "description": "Optional reason for the cancellation (e.g., \"user action\", \"timeout\").", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["method", "params"], + "additionalProperties": false + }, "McpUiToolInputNotification": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", diff --git a/src/generated/schema.test.ts b/src/generated/schema.test.ts index 5a2e3a60..1751067e 100644 --- a/src/generated/schema.test.ts +++ b/src/generated/schema.test.ts @@ -51,6 +51,10 @@ export type McpUiToolInputPartialNotificationSchemaInferredType = z.infer< typeof generated.McpUiToolInputPartialNotificationSchema >; +export type McpUiToolCancelledNotificationSchemaInferredType = z.infer< + typeof generated.McpUiToolCancelledNotificationSchema +>; + export type McpUiResourceTeardownRequestSchemaInferredType = z.infer< typeof generated.McpUiResourceTeardownRequestSchema >; @@ -151,6 +155,12 @@ expectType( expectType( {} as spec.McpUiToolInputPartialNotification, ); +expectType( + {} as McpUiToolCancelledNotificationSchemaInferredType, +); +expectType( + {} as spec.McpUiToolCancelledNotification, +); expectType( {} as McpUiResourceTeardownRequestSchemaInferredType, ); diff --git a/src/generated/schema.ts b/src/generated/schema.ts index 1b181afa..8cc87fde 100644 --- a/src/generated/schema.ts +++ b/src/generated/schema.ts @@ -160,6 +160,24 @@ export const McpUiToolInputPartialNotificationSchema = z.object({ }), }); +/** + * @description Notification that tool execution was cancelled (Host -> Guest UI). + * Host MUST send this if tool execution was cancelled for any reason (user action, + * sampling error, classifier intervention, etc.). + */ +export const McpUiToolCancelledNotificationSchema = z.object({ + method: z.literal("ui/notifications/tool-cancelled"), + params: z.object({ + /** @description Optional reason for the cancellation (e.g., "user action", "timeout"). */ + reason: z + .string() + .optional() + .describe( + 'Optional reason for the cancellation (e.g., "user action", "timeout").', + ), + }), +}); + /** * @description Request for graceful shutdown of the Guest UI (Host -> Guest UI). * @see {@link app-bridge.AppBridge.sendResourceTeardown} for the host method that sends this diff --git a/src/spec.types.ts b/src/spec.types.ts index 2596bff5..84713847 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -167,6 +167,19 @@ export interface McpUiToolResultNotification { params: CallToolResult; } +/** + * @description Notification that tool execution was cancelled (Host -> Guest UI). + * Host MUST send this if tool execution was cancelled for any reason (user action, + * sampling error, classifier intervention, etc.). + */ +export interface McpUiToolCancelledNotification { + method: "ui/notifications/tool-cancelled"; + params: { + /** @description Optional reason for the cancellation (e.g., "user action", "timeout"). */ + reason?: string; + }; +} + /** * @description Rich context about the host environment provided to Guest UIs. */ diff --git a/src/types.ts b/src/types.ts index 92bb8cf8..5ed81313 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,6 +24,7 @@ export { type McpUiToolInputNotification, type McpUiToolInputPartialNotification, type McpUiToolResultNotification, + type McpUiToolCancelledNotification, type McpUiHostContext, type McpUiHostContextChangedNotification, type McpUiResourceTeardownRequest, @@ -51,6 +52,7 @@ export { McpUiToolInputNotificationSchema, McpUiToolInputPartialNotificationSchema, McpUiToolResultNotificationSchema, + McpUiToolCancelledNotificationSchema, McpUiHostContextSchema, McpUiHostContextChangedNotificationSchema, McpUiResourceTeardownRequestSchema, From 8d79f8704c1fdfd4a34375b7745c59b506790953 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 10 Dec 2025 03:25:33 +0100 Subject: [PATCH 2/5] feat: add App.onteardown handler for graceful shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app-bridge.test.ts | 28 +++++++++++++++++++++++++ src/app.ts | 46 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index b31e1a43..43db7b23 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -203,6 +203,34 @@ describe("App <-> AppBridge integration", () => { { theme: "light" }, ]); }); + + it("sendResourceTeardown triggers app.onteardown", async () => { + let teardownCalled = false; + app.onteardown = async () => { + teardownCalled = true; + return {}; + }; + + await app.connect(appTransport); + await bridge.sendResourceTeardown({}); + + expect(teardownCalled).toBe(true); + }); + + it("sendResourceTeardown waits for async cleanup", async () => { + const cleanupSteps: string[] = []; + app.onteardown = async () => { + cleanupSteps.push("start"); + await new Promise((resolve) => setTimeout(resolve, 10)); + cleanupSteps.push("done"); + return {}; + }; + + await app.connect(appTransport); + await bridge.sendResourceTeardown({}); + + expect(cleanupSteps).toEqual(["start", "done"]); + }); }); describe("App -> Host notifications", () => { diff --git a/src/app.ts b/src/app.ts index 2cd68b46..15261136 100644 --- a/src/app.ts +++ b/src/app.ts @@ -31,6 +31,9 @@ import { McpUiMessageResultSchema, McpUiOpenLinkRequest, McpUiOpenLinkResultSchema, + McpUiResourceTeardownRequest, + McpUiResourceTeardownRequestSchema, + McpUiResourceTeardownResult, McpUiSizeChangedNotification, McpUiToolCancelledNotification, McpUiToolCancelledNotificationSchema, @@ -464,6 +467,48 @@ export class App extends Protocol { ); } + /** + * Convenience handler for graceful shutdown requests from the host. + * + * Set this property to register a handler that will be called when the host + * requests the app to prepare for teardown. This allows the app to perform + * cleanup operations (save state, close connections, etc.) before being unmounted. + * + * The handler can be sync or async. The host will wait for the returned promise + * to resolve before proceeding with teardown. + * + * This setter is a convenience wrapper around `setRequestHandler()` that + * automatically handles the request schema. + * + * Register handlers before calling {@link connect} to avoid missing requests. + * + * @param callback - Function called when teardown is requested. + * Can return void or a Promise that resolves when cleanup is complete. + * + * @example Perform cleanup before teardown + * ```typescript + * app.onteardown = async () => { + * await saveState(); + * closeConnections(); + * console.log("App ready for teardown"); + * }; + * ``` + * + * @see {@link setRequestHandler} for the underlying method + * @see {@link McpUiResourceTeardownRequest} for the request structure + */ + set onteardown( + callback: ( + params: McpUiResourceTeardownRequest["params"], + extra: RequestHandlerExtra, + ) => McpUiResourceTeardownResult | Promise, + ) { + this.setRequestHandler( + McpUiResourceTeardownRequestSchema, + (request, extra) => callback(request.params, extra), + ); + } + /** * Convenience handler for tool call requests from the host. * @@ -570,6 +615,7 @@ export class App extends Protocol { } return; case "ping": + case "ui/resource-teardown": return; default: throw new Error(`No handler for method ${method} registered`); From b16880d64b2c35778467b6ec33a19135288fb1ac Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 10 Dec 2025 04:13:24 +0100 Subject: [PATCH 3/5] feat(example): wire tool-cancelled and teardown in basic-host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- examples/basic-host/src/implementation.ts | 18 +++++++++++++----- examples/basic-host/src/index.tsx | 12 ++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/examples/basic-host/src/implementation.ts b/examples/basic-host/src/implementation.ts index d1d1bcc6..9ab57591 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.tsx b/examples/basic-host/src/index.tsx index 37eac43f..eb324884 100644 --- a/examples/basic-host/src/index.tsx +++ b/examples/basic-host/src/index.tsx @@ -176,6 +176,7 @@ interface AppIFramePanelProps { } function AppIFramePanel({ toolCallInfo }: AppIFramePanelProps) { const iframeRef = useRef(null); + const appBridgeRef = useRef | null>(null); useEffect(() => { const iframe = iframeRef.current!; @@ -186,9 +187,20 @@ function AppIFramePanel({ toolCallInfo }: AppIFramePanelProps) { // `toolCallInfo`. if (firstTime) { const appBridge = newAppBridge(toolCallInfo.serverInfo, iframe); + appBridgeRef.current = appBridge; initializeApp(iframe, appBridge, toolCallInfo); } }); + + // Cleanup: send teardown notification before unmounting + return () => { + if (appBridgeRef.current) { + log.info("Sending teardown notification to MCP App"); + appBridgeRef.current.sendResourceTeardown({}).catch((err) => { + log.warn("Teardown request failed (app may have already closed):", err); + }); + } + }; }, [toolCallInfo]); return ( From bf3b1b36302738687dca78d44191325a5a00ac3f Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 10 Dec 2025 18:47:06 +0100 Subject: [PATCH 4/5] fix(example): implement graceful teardown per spec recommendation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- examples/basic-host/src/index.module.css | 22 +++++ examples/basic-host/src/index.tsx | 104 ++++++++++++++++++----- 2 files changed, 107 insertions(+), 19 deletions(-) diff --git a/examples/basic-host/src/index.module.css b/examples/basic-host/src/index.module.css index c162aa8c..0c4a8b0a 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 eb324884..1288a340 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,8 +225,10 @@ 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); @@ -191,18 +245,30 @@ function AppIFramePanel({ toolCallInfo }: AppIFramePanelProps) { initializeApp(iframe, appBridge, toolCallInfo); } }); - - // Cleanup: send teardown notification before unmounting - return () => { - if (appBridgeRef.current) { - log.info("Sending teardown notification to MCP App"); - appBridgeRef.current.sendResourceTeardown({}).catch((err) => { - log.warn("Teardown request failed (app may have already closed):", err); - }); - } - }; }, [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 (