Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
22 changes: 22 additions & 0 deletions examples/basic-host/src/index.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
}

Expand Down
96 changes: 87 additions & 9 deletions examples/basic-host/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,41 @@ import styles from "./index.module.css";
interface HostProps {
serversPromise: Promise<ServerInfo[]>;
}

type ToolCallEntry = ToolCallInfo & { id: number };
let nextToolCallId = 0;

function Host({ serversPromise }: HostProps) {
const [toolCalls, setToolCalls] = useState<ToolCallInfo[]>([]);
const [toolCalls, setToolCalls] = useState<ToolCallEntry[]>([]);
const [destroyingIds, setDestroyingIds] = useState<Set<number>>(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) => (
<ToolCallInfoPanel key={i} toolCallInfo={info} />
{toolCalls.map((info) => (
<ToolCallInfoPanel
key={info.id}
toolCallInfo={info}
isDestroying={destroyingIds.has(info.id)}
onRequestClose={() => requestClose(info.id)}
onCloseComplete={() => completeClose(info.id)}
/>
))}
<CallToolPanel
serversPromise={serversPromise}
addToolCall={(info) => setToolCalls([...toolCalls, info])}
addToolCall={(info) => setToolCalls([...toolCalls, { ...info, id: nextToolCallId++ }])}
/>
</>
);
Expand Down Expand Up @@ -135,23 +159,51 @@ 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 (
<div className={styles.toolCallInfoPanel}>
<div
className={styles.toolCallInfoPanel}
style={isDestroying ? { opacity: 0.5, pointerEvents: "none" } : undefined}
>
<div className={styles.inputInfoPanel}>
<h2>
<span>{toolCallInfo.serverInfo.name}</span>
<span className={styles.toolName}>{toolCallInfo.tool.name}</span>
{onRequestClose && !isDestroying && (
<button
className={styles.closeButton}
onClick={onRequestClose}
title="Close"
>
×
</button>
)}
</h2>
<JsonBlock value={toolCallInfo.input} />
</div>
<div className={styles.outputInfoPanel}>
<ErrorBoundary>
<Suspense fallback="Loading...">
{
hasAppHtml(toolCallInfo)
? <AppIFramePanel toolCallInfo={toolCallInfo} />
isApp
? <AppIFramePanel
toolCallInfo={toolCallInfo}
isDestroying={isDestroying}
onTeardownComplete={onCloseComplete}
/>
: <ToolResultPanel toolCallInfo={toolCallInfo} />
}
</Suspense>
Expand All @@ -173,9 +225,12 @@ function JsonBlock({ value }: { value: object }) {

interface AppIFramePanelProps {
toolCallInfo: Required<ToolCallInfo>;
isDestroying?: boolean;
onTeardownComplete?: () => void;
}
function AppIFramePanel({ toolCallInfo }: AppIFramePanelProps) {
function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppIFramePanelProps) {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const appBridgeRef = useRef<ReturnType<typeof newAppBridge> | null>(null);

useEffect(() => {
const iframe = iframeRef.current!;
Expand All @@ -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 (
<div className={styles.appIframePanel}>
<iframe ref={iframeRef} />
Expand Down
8 changes: 7 additions & 1 deletion examples/basic-server-react/src/mcp-app.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* @file App that demonstrates a few features using MCP Apps SDK + React.
*/
import type { App } from "@modelcontextprotocol/ext-apps";
import type { App, McpUiResourceTeardownResult } from "@modelcontextprotocol/ext-apps";
import { useApp } from "@modelcontextprotocol/ext-apps/react";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { StrictMode, useCallback, useEffect, useState } from "react";
Expand Down Expand Up @@ -35,6 +35,12 @@ function GetTimeApp() {
appInfo: IMPLEMENTATION,
capabilities: {},
onAppCreated: (app) => {
app.onteardown = async () => {
log.info("App is being torn down");
await new Promise((resolve) => setTimeout(resolve, 500)); // Simulate cleanup work
log.info("App teardown complete");
return {};
};
app.ontoolinput = async (input) => {
log.info("Received tool call input:", input);
};
Expand Down
6 changes: 6 additions & 0 deletions examples/basic-server-vanillajs/src/mcp-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ const openLinkBtn = document.getElementById("open-link-btn")!;
// Create app instance
const app = new App({ name: "Get Time App", version: "1.0.0" });

app.onteardown = async () => {
log.info("App is being torn down");
await new Promise((resolve) => setTimeout(resolve, 500)); // Simulate cleanup work
log.info("App teardown complete");
return {};
};

// Register handlers BEFORE connecting
app.ontoolinput = (params) => {
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
Loading
Loading