ext-apps/examples/basic-server-react/src/mcp-app.tsx
Olivier Chafik 70b393a7e6
feat: implement ui/notifications/tool-cancelled + add missing App.onteardown (#120)
* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-11 20:31:17 +01:00

151 lines
4.8 KiB
TypeScript

/**
* @file App that demonstrates a few features using MCP Apps SDK + React.
*/
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";
import { createRoot } from "react-dom/client";
import styles from "./mcp-app.module.css";
const IMPLEMENTATION = { name: "Get Time App", version: "1.0.0" };
const log = {
info: console.log.bind(console, "[APP]"),
warn: console.warn.bind(console, "[APP]"),
error: console.error.bind(console, "[APP]"),
};
function extractTime(callToolResult: CallToolResult): string {
const text = callToolResult.content!
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("");
const { time } = JSON.parse(text) as { time: string };
return time;
}
function GetTimeApp() {
const [toolResult, setToolResult] = useState<CallToolResult | null>(null);
const { app, error } = useApp({
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);
};
app.ontoolresult = async (result) => {
log.info("Received tool call result:", result);
setToolResult(result);
};
app.onerror = log.error;
},
});
if (error) return <div><strong>ERROR:</strong> {error.message}</div>;
if (!app) return <div>Connecting...</div>;
return <GetTimeAppInner app={app} toolResult={toolResult} />;
}
interface GetTimeAppInnerProps {
app: App;
toolResult: CallToolResult | null;
}
function GetTimeAppInner({ app, toolResult }: GetTimeAppInnerProps) {
const [serverTime, setServerTime] = useState("Loading...");
const [messageText, setMessageText] = useState("This is message text.");
const [logText, setLogText] = useState("This is log text.");
const [linkUrl, setLinkUrl] = useState("https://modelcontextprotocol.io/");
useEffect(() => {
if (toolResult) {
setServerTime(extractTime(toolResult));
}
}, [toolResult]);
const handleGetTime = useCallback(async () => {
try {
log.info("Calling get-time tool...");
const result = await app.callServerTool({ name: "get-time", arguments: {} });
log.info("get-time result:", result);
setServerTime(extractTime(result));
} catch (e) {
log.error(e);
setServerTime("[ERROR]");
}
}, [app]);
const handleSendMessage = useCallback(async () => {
const signal = AbortSignal.timeout(5000);
try {
log.info("Sending message text to Host:", messageText);
const { isError } = await app.sendMessage(
{ role: "user", content: [{ type: "text", text: messageText }] },
{ signal },
);
log.info("Message", isError ? "rejected" : "accepted");
} catch (e) {
log.error("Message send error:", signal.aborted ? "timed out" : e);
}
}, [app, messageText]);
const handleSendLog = useCallback(async () => {
log.info("Sending log text to Host:", logText);
await app.sendLog({ level: "info", data: logText });
}, [app, logText]);
const handleOpenLink = useCallback(async () => {
log.info("Sending open link request to Host:", linkUrl);
const { isError } = await app.sendOpenLink({ url: linkUrl });
log.info("Open link request", isError ? "rejected" : "accepted");
}, [app, linkUrl]);
return (
<main className={styles.main}>
<p className={styles.notice}>Watch activity in the DevTools console!</p>
<div className={styles.action}>
<p>
<strong>Server Time:</strong> <code>{serverTime}</code>
</p>
<button onClick={handleGetTime}>Get Server Time</button>
</div>
<div className={styles.action}>
<textarea value={messageText} onChange={(e) => setMessageText(e.target.value)} />
<button onClick={handleSendMessage}>Send Message</button>
</div>
<div className={styles.action}>
<input type="text" value={logText} onChange={(e) => setLogText(e.target.value)} />
<button onClick={handleSendLog}>Send Log</button>
</div>
<div className={styles.action}>
<input type="url" value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)} />
<button onClick={handleOpenLink}>Open Link</button>
</div>
</main>
);
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<GetTimeApp />
</StrictMode>,
);