Skip to content

Commit 5277436

Browse files
ochafikclaude
andcommitted
Merge origin/main into ochafik/swift-sdk
Resolved conflict in examples/basic-host/src/index.tsx: - Keep toolCallInfo parameter for hostContext.toolInfo - Keep appBridgeRef.current assignment for teardown support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
2 parents af07189 + 70b393a commit 5277436

File tree

14 files changed

+382
-17
lines changed

14 files changed

+382
-17
lines changed

examples/basic-host/src/implementation.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -186,11 +186,19 @@ export async function initializeApp(
186186
log.info("Sending tool call input to MCP App:", input);
187187
appBridge.sendToolInput({ arguments: input });
188188

189-
// Schedule tool call result to be sent to MCP App
190-
resultPromise.then((result) => {
191-
log.info("Sending tool call result to MCP App:", result);
192-
appBridge.sendToolResult(result);
193-
});
189+
// Schedule tool call result (or cancellation) to be sent to MCP App
190+
resultPromise.then(
191+
(result) => {
192+
log.info("Sending tool call result to MCP App:", result);
193+
appBridge.sendToolResult(result);
194+
},
195+
(error) => {
196+
log.error("Tool call failed, sending cancellation to MCP App:", error);
197+
appBridge.sendToolCancelled({
198+
reason: error instanceof Error ? error.message : String(error),
199+
});
200+
},
201+
);
194202
}
195203

196204
/**

examples/basic-host/src/index.module.css

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,32 @@
9292
flex-direction: column;
9393
margin: 0;
9494
font-size: 1.5rem;
95+
position: relative;
9596

9697
.toolName {
9798
font-family: monospace;
9899
}
100+
101+
.closeButton {
102+
position: absolute;
103+
top: 0;
104+
right: 0;
105+
width: 1.5rem;
106+
height: 1.5rem;
107+
padding: 0;
108+
border: none;
109+
border-radius: 4px;
110+
background: #e0e0e0;
111+
font-size: 1.25rem;
112+
line-height: 1;
113+
color: #666;
114+
cursor: pointer;
115+
116+
&:hover {
117+
background: #d0d0d0;
118+
color: #333;
119+
}
120+
}
99121
}
100122
}
101123

examples/basic-host/src/index.tsx

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,41 @@ import styles from "./index.module.css";
88
interface HostProps {
99
serversPromise: Promise<ServerInfo[]>;
1010
}
11+
12+
type ToolCallEntry = ToolCallInfo & { id: number };
13+
let nextToolCallId = 0;
14+
1115
function Host({ serversPromise }: HostProps) {
12-
const [toolCalls, setToolCalls] = useState<ToolCallInfo[]>([]);
16+
const [toolCalls, setToolCalls] = useState<ToolCallEntry[]>([]);
17+
const [destroyingIds, setDestroyingIds] = useState<Set<number>>(new Set());
18+
19+
const requestClose = (id: number) => {
20+
setDestroyingIds((s) => new Set(s).add(id));
21+
};
22+
23+
const completeClose = (id: number) => {
24+
setDestroyingIds((s) => {
25+
const next = new Set(s);
26+
next.delete(id);
27+
return next;
28+
});
29+
setToolCalls((calls) => calls.filter((c) => c.id !== id));
30+
};
1331

1432
return (
1533
<>
16-
{toolCalls.map((info, i) => (
17-
<ToolCallInfoPanel key={i} toolCallInfo={info} />
34+
{toolCalls.map((info) => (
35+
<ToolCallInfoPanel
36+
key={info.id}
37+
toolCallInfo={info}
38+
isDestroying={destroyingIds.has(info.id)}
39+
onRequestClose={() => requestClose(info.id)}
40+
onCloseComplete={() => completeClose(info.id)}
41+
/>
1842
))}
1943
<CallToolPanel
2044
serversPromise={serversPromise}
21-
addToolCall={(info) => setToolCalls([...toolCalls, info])}
45+
addToolCall={(info) => setToolCalls([...toolCalls, { ...info, id: nextToolCallId++ }])}
2246
/>
2347
</>
2448
);
@@ -135,23 +159,51 @@ function ServerSelect({ serversPromise, onSelect }: ServerSelectProps) {
135159

136160
interface ToolCallInfoPanelProps {
137161
toolCallInfo: ToolCallInfo;
162+
isDestroying?: boolean;
163+
onRequestClose?: () => void;
164+
onCloseComplete?: () => void;
138165
}
139-
function ToolCallInfoPanel({ toolCallInfo }: ToolCallInfoPanelProps) {
166+
function ToolCallInfoPanel({ toolCallInfo, isDestroying, onRequestClose, onCloseComplete }: ToolCallInfoPanelProps) {
167+
const isApp = hasAppHtml(toolCallInfo);
168+
169+
// For non-app tool calls, close immediately when isDestroying becomes true
170+
useEffect(() => {
171+
if (isDestroying && !isApp) {
172+
onCloseComplete?.();
173+
}
174+
}, [isDestroying, isApp, onCloseComplete]);
175+
140176
return (
141-
<div className={styles.toolCallInfoPanel}>
177+
<div
178+
className={styles.toolCallInfoPanel}
179+
style={isDestroying ? { opacity: 0.5, pointerEvents: "none" } : undefined}
180+
>
142181
<div className={styles.inputInfoPanel}>
143182
<h2>
144183
<span>{toolCallInfo.serverInfo.name}</span>
145184
<span className={styles.toolName}>{toolCallInfo.tool.name}</span>
185+
{onRequestClose && !isDestroying && (
186+
<button
187+
className={styles.closeButton}
188+
onClick={onRequestClose}
189+
title="Close"
190+
>
191+
×
192+
</button>
193+
)}
146194
</h2>
147195
<JsonBlock value={toolCallInfo.input} />
148196
</div>
149197
<div className={styles.outputInfoPanel}>
150198
<ErrorBoundary>
151199
<Suspense fallback="Loading...">
152200
{
153-
hasAppHtml(toolCallInfo)
154-
? <AppIFramePanel toolCallInfo={toolCallInfo} />
201+
isApp
202+
? <AppIFramePanel
203+
toolCallInfo={toolCallInfo}
204+
isDestroying={isDestroying}
205+
onTeardownComplete={onCloseComplete}
206+
/>
155207
: <ToolResultPanel toolCallInfo={toolCallInfo} />
156208
}
157209
</Suspense>
@@ -173,9 +225,12 @@ function JsonBlock({ value }: { value: object }) {
173225

174226
interface AppIFramePanelProps {
175227
toolCallInfo: Required<ToolCallInfo>;
228+
isDestroying?: boolean;
229+
onTeardownComplete?: () => void;
176230
}
177-
function AppIFramePanel({ toolCallInfo }: AppIFramePanelProps) {
231+
function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppIFramePanelProps) {
178232
const iframeRef = useRef<HTMLIFrameElement | null>(null);
233+
const appBridgeRef = useRef<ReturnType<typeof newAppBridge> | null>(null);
179234

180235
useEffect(() => {
181236
const iframe = iframeRef.current!;
@@ -186,11 +241,34 @@ function AppIFramePanel({ toolCallInfo }: AppIFramePanelProps) {
186241
// `toolCallInfo`.
187242
if (firstTime) {
188243
const appBridge = newAppBridge(toolCallInfo.serverInfo, toolCallInfo, iframe);
244+
appBridgeRef.current = appBridge;
189245
initializeApp(iframe, appBridge, toolCallInfo);
190246
}
191247
});
192248
}, [toolCallInfo]);
193249

250+
// Graceful teardown: wait for guest to respond before unmounting
251+
// This follows the spec: "Host SHOULD wait for a response before tearing
252+
// down the resource (to prevent data loss)."
253+
useEffect(() => {
254+
if (!isDestroying) return;
255+
256+
if (!appBridgeRef.current) {
257+
// Bridge not ready yet (e.g., user closed before iframe loaded)
258+
onTeardownComplete?.();
259+
return;
260+
}
261+
262+
log.info("Sending teardown notification to MCP App");
263+
appBridgeRef.current.sendResourceTeardown({})
264+
.catch((err) => {
265+
log.warn("Teardown request failed (app may have already closed):", err);
266+
})
267+
.finally(() => {
268+
onTeardownComplete?.();
269+
});
270+
}, [isDestroying, onTeardownComplete]);
271+
194272
return (
195273
<div className={styles.appIframePanel}>
196274
<iframe ref={iframeRef} />

examples/basic-server-react/src/mcp-app.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @file App that demonstrates a few features using MCP Apps SDK + React.
33
*/
4-
import type { App } from "@modelcontextprotocol/ext-apps";
4+
import type { App, McpUiResourceTeardownResult } from "@modelcontextprotocol/ext-apps";
55
import { useApp } from "@modelcontextprotocol/ext-apps/react";
66
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
77
import { StrictMode, useCallback, useEffect, useState } from "react";
@@ -35,6 +35,12 @@ function GetTimeApp() {
3535
appInfo: IMPLEMENTATION,
3636
capabilities: {},
3737
onAppCreated: (app) => {
38+
app.onteardown = async () => {
39+
log.info("App is being torn down");
40+
await new Promise((resolve) => setTimeout(resolve, 500)); // Simulate cleanup work
41+
log.info("App teardown complete");
42+
return {};
43+
};
3844
app.ontoolinput = async (input) => {
3945
log.info("Received tool call input:", input);
4046
};

examples/basic-server-vanillajs/src/mcp-app.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ const openLinkBtn = document.getElementById("open-link-btn")!;
3838
// Create app instance
3939
const app = new App({ name: "Get Time App", version: "1.0.0" });
4040

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

4248
// Register handlers BEFORE connecting
4349
app.ontoolinput = (params) => {

specification/draft/apps.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -530,8 +530,9 @@ Host SHOULD open the URL in the user's default browser or a new tab.
530530
}
531531
}
532532
```
533-
534-
Host SHOULD add the message to the conversation thread, preserving the specified role.
533+
Host behavior:
534+
* Host SHOULD add the message to the conversation context, preserving the specified role.
535+
* Host MAY request user consent.
535536

536537
#### Notifications (Host → UI)
537538

src/app-bridge.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,36 @@ describe("App <-> AppBridge integration", () => {
140140
});
141141
});
142142

143+
it("sendToolCancelled triggers app.ontoolcancelled", async () => {
144+
const receivedCancellations: unknown[] = [];
145+
app.ontoolcancelled = (params) => {
146+
receivedCancellations.push(params);
147+
};
148+
149+
await app.connect(appTransport);
150+
await bridge.sendToolCancelled({
151+
reason: "User cancelled the operation",
152+
});
153+
154+
expect(receivedCancellations).toHaveLength(1);
155+
expect(receivedCancellations[0]).toEqual({
156+
reason: "User cancelled the operation",
157+
});
158+
});
159+
160+
it("sendToolCancelled works without reason", async () => {
161+
const receivedCancellations: unknown[] = [];
162+
app.ontoolcancelled = (params) => {
163+
receivedCancellations.push(params);
164+
};
165+
166+
await app.connect(appTransport);
167+
await bridge.sendToolCancelled({});
168+
169+
expect(receivedCancellations).toHaveLength(1);
170+
expect(receivedCancellations[0]).toEqual({});
171+
});
172+
143173
it("setHostContext triggers app.onhostcontextchanged", async () => {
144174
const receivedContexts: unknown[] = [];
145175
app.onhostcontextchanged = (params) => {
@@ -173,6 +203,34 @@ describe("App <-> AppBridge integration", () => {
173203
{ theme: "light" },
174204
]);
175205
});
206+
207+
it("sendResourceTeardown triggers app.onteardown", async () => {
208+
let teardownCalled = false;
209+
app.onteardown = async () => {
210+
teardownCalled = true;
211+
return {};
212+
};
213+
214+
await app.connect(appTransport);
215+
await bridge.sendResourceTeardown({});
216+
217+
expect(teardownCalled).toBe(true);
218+
});
219+
220+
it("sendResourceTeardown waits for async cleanup", async () => {
221+
const cleanupSteps: string[] = [];
222+
app.onteardown = async () => {
223+
cleanupSteps.push("start");
224+
await new Promise((resolve) => setTimeout(resolve, 10));
225+
cleanupSteps.push("done");
226+
return {};
227+
};
228+
229+
await app.connect(appTransport);
230+
await bridge.sendResourceTeardown({});
231+
232+
expect(cleanupSteps).toEqual(["start", "done"]);
233+
});
176234
});
177235

178236
describe("App -> Host notifications", () => {

src/app-bridge.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
import {
3535
type McpUiSandboxResourceReadyNotification,
3636
type McpUiSizeChangedNotification,
37+
type McpUiToolCancelledNotification,
3738
type McpUiToolInputNotification,
3839
type McpUiToolInputPartialNotification,
3940
type McpUiToolResultNotification,
@@ -737,6 +738,43 @@ export class AppBridge extends Protocol<Request, Notification, Result> {
737738
});
738739
}
739740

741+
/**
742+
* Notify the Guest UI that tool execution was cancelled.
743+
*
744+
* The host MUST send this notification if tool execution was cancelled for any
745+
* reason, including user action, sampling error, classifier intervention, or
746+
* any other interruption. This allows the Guest UI to update its state and
747+
* display appropriate feedback to the user.
748+
*
749+
* @param params - Optional cancellation details:
750+
* - `reason`: Human-readable explanation for why the tool was cancelled
751+
*
752+
* @example User-initiated cancellation
753+
* ```typescript
754+
* // User clicked "Cancel" button
755+
* bridge.sendToolCancelled({ reason: "User cancelled the operation" });
756+
* ```
757+
*
758+
* @example System-level cancellation
759+
* ```typescript
760+
* // Sampling error or timeout
761+
* bridge.sendToolCancelled({ reason: "Request timeout after 30 seconds" });
762+
*
763+
* // Classifier intervention
764+
* bridge.sendToolCancelled({ reason: "Content policy violation detected" });
765+
* ```
766+
*
767+
* @see {@link McpUiToolCancelledNotification} for the notification type
768+
* @see {@link sendToolResult} for sending successful results
769+
* @see {@link sendToolInput} for sending tool arguments
770+
*/
771+
sendToolCancelled(params: McpUiToolCancelledNotification["params"]) {
772+
return this.notification(<McpUiToolCancelledNotification>{
773+
method: "ui/notifications/tool-cancelled",
774+
params,
775+
});
776+
}
777+
740778
/**
741779
* Send HTML resource to the sandbox proxy for secure loading.
742780
*

0 commit comments

Comments
 (0)