Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions examples/basic-host/src/implementation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
},
);
}

/**
Expand Down
12 changes: 12 additions & 0 deletions examples/basic-host/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ interface AppIFramePanelProps {
}
function AppIFramePanel({ toolCallInfo }: AppIFramePanelProps) {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const appBridgeRef = useRef<ReturnType<typeof newAppBridge> | null>(null);

useEffect(() => {
const iframe = iframeRef.current!;
Expand All @@ -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) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ochafik it's worth noting here that this doesn't actually wait for the UI to terminate gracefully before unmounting, which is against against the spec's recommendation (Host SHOULD wait for a response before tearing down the resource (to prevent data loss).)

Copy link
Contributor Author

@ochafik ochafik Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! Fixed in bf3b1b3 - the host now waits for the guest's teardown response before unmounting, per the spec recommendation.

Changes:

  • Added close button (×) to tool call panels
  • Two-phase unmount: clicking close sets isDestroying state which dims the panel and disables pointer events
  • sendResourceTeardown() completes (or errors) before the component is removed
  • Also handles edge case where user closes before bridge is ready

Demo apps now also have onteardown handlers with simulated 500ms cleanup to demonstrate the flow.

log.warn("Teardown request failed (app may have already closed):", err);
});
}
};
}, [toolCallInfo]);

return (
Expand Down
58 changes: 58 additions & 0 deletions src/app-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -173,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", () => {
Expand Down
38 changes: 38 additions & 0 deletions src/app-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
import {
type McpUiSandboxResourceReadyNotification,
type McpUiSizeChangedNotification,
type McpUiToolCancelledNotification,
type McpUiToolInputNotification,
type McpUiToolInputPartialNotification,
type McpUiToolResultNotification,
Expand Down Expand Up @@ -737,6 +738,43 @@ export class AppBridge extends Protocol<Request, Notification, Result> {
});
}

/**
* 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(<McpUiToolCancelledNotification>{
method: "ui/notifications/tool-cancelled",
params,
});
}

/**
* Send HTML resource to the sandbox proxy for secure loading.
*
Expand Down
83 changes: 83 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ import {
McpUiMessageResultSchema,
McpUiOpenLinkRequest,
McpUiOpenLinkResultSchema,
McpUiResourceTeardownRequest,
McpUiResourceTeardownRequestSchema,
McpUiResourceTeardownResult,
McpUiSizeChangedNotification,
McpUiToolCancelledNotification,
McpUiToolCancelledNotificationSchema,
McpUiToolInputNotification,
McpUiToolInputNotificationSchema,
McpUiToolInputPartialNotification,
Expand Down Expand Up @@ -388,6 +393,41 @@ export class App extends Protocol<Request, Notification, Result> {
);
}

/**
* 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.).
*
Expand Down Expand Up @@ -427,6 +467,48 @@ export class App extends Protocol<Request, Notification, Result> {
);
}

/**
* 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<McpUiResourceTeardownResult>,
) {
this.setRequestHandler(
McpUiResourceTeardownRequestSchema,
(request, extra) => callback(request.params, extra),
);
}

/**
* Convenience handler for tool call requests from the host.
*
Expand Down Expand Up @@ -533,6 +615,7 @@ export class App extends Protocol<Request, Notification, Result> {
}
return;
case "ping":
case "ui/resource-teardown":
return;
default:
throw new Error(`No handler for method ${method} registered`);
Expand Down
22 changes: 22 additions & 0 deletions src/generated/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions src/generated/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
>;
Expand Down Expand Up @@ -151,6 +155,12 @@ expectType<spec.McpUiToolInputPartialNotification>(
expectType<McpUiToolInputPartialNotificationSchemaInferredType>(
{} as spec.McpUiToolInputPartialNotification,
);
expectType<spec.McpUiToolCancelledNotification>(
{} as McpUiToolCancelledNotificationSchemaInferredType,
);
expectType<McpUiToolCancelledNotificationSchemaInferredType>(
{} as spec.McpUiToolCancelledNotification,
);
expectType<spec.McpUiResourceTeardownRequest>(
{} as McpUiResourceTeardownRequestSchemaInferredType,
);
Expand Down
18 changes: 18 additions & 0 deletions src/generated/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/spec.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Loading
Loading