Skip to content

Commit 23d0e2e

Browse files
committed
Merge branch 'main' into ochafik/e2e-tests
2 parents 53012ae + 8448ffe commit 23d0e2e

File tree

39 files changed

+888
-570
lines changed

39 files changed

+888
-570
lines changed

.github/workflows/ci.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ jobs:
3333
- name: Verify generated schemas are up-to-date
3434
run: |
3535
npm run generate:schemas
36-
npm run prettier:fix
3736
git diff --exit-code src/generated/ || (echo "Generated schemas are out of date. Run 'npm run generate:schemas' and commit." && exit 1)
3837
3938
- run: npm test

docs/quickstart.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ import cors from "cors";
102102
import express from "express";
103103
import fs from "node:fs/promises";
104104
import path from "node:path";
105-
import { z } from "zod";
105+
import * as z from "zod";
106106

107107
const server = new McpServer({
108108
name: "My MCP App Server",

examples/basic-host/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"@modelcontextprotocol/sdk": "^1.22.0",
1616
"react": "^19.2.0",
1717
"react-dom": "^19.2.0",
18-
"zod": "^3.25.0"
18+
"zod": "^4.1.13"
1919
},
2020
"devDependencies": {
2121
"@types/express": "^5.0.0",

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
@@ -29,17 +29,41 @@ function getToolDefaults(tool: Tool | undefined): string {
2929
interface HostProps {
3030
serversPromise: Promise<ServerInfo[]>;
3131
}
32+
33+
type ToolCallEntry = ToolCallInfo & { id: number };
34+
let nextToolCallId = 0;
35+
3236
function Host({ serversPromise }: HostProps) {
33-
const [toolCalls, setToolCalls] = useState<ToolCallInfo[]>([]);
37+
const [toolCalls, setToolCalls] = useState<ToolCallEntry[]>([]);
38+
const [destroyingIds, setDestroyingIds] = useState<Set<number>>(new Set());
39+
40+
const requestClose = (id: number) => {
41+
setDestroyingIds((s) => new Set(s).add(id));
42+
};
43+
44+
const completeClose = (id: number) => {
45+
setDestroyingIds((s) => {
46+
const next = new Set(s);
47+
next.delete(id);
48+
return next;
49+
});
50+
setToolCalls((calls) => calls.filter((c) => c.id !== id));
51+
};
3452

3553
return (
3654
<>
37-
{toolCalls.map((info, i) => (
38-
<ToolCallInfoPanel key={i} toolCallInfo={info} />
55+
{toolCalls.map((info) => (
56+
<ToolCallInfoPanel
57+
key={info.id}
58+
toolCallInfo={info}
59+
isDestroying={destroyingIds.has(info.id)}
60+
onRequestClose={() => requestClose(info.id)}
61+
onCloseComplete={() => completeClose(info.id)}
62+
/>
3963
))}
4064
<CallToolPanel
4165
serversPromise={serversPromise}
42-
addToolCall={(info) => setToolCalls([...toolCalls, info])}
66+
addToolCall={(info) => setToolCalls([...toolCalls, { ...info, id: nextToolCallId++ }])}
4367
/>
4468
</>
4569
);
@@ -164,23 +188,51 @@ function ServerSelect({ serversPromise, onSelect }: ServerSelectProps) {
164188

165189
interface ToolCallInfoPanelProps {
166190
toolCallInfo: ToolCallInfo;
191+
isDestroying?: boolean;
192+
onRequestClose?: () => void;
193+
onCloseComplete?: () => void;
167194
}
168-
function ToolCallInfoPanel({ toolCallInfo }: ToolCallInfoPanelProps) {
195+
function ToolCallInfoPanel({ toolCallInfo, isDestroying, onRequestClose, onCloseComplete }: ToolCallInfoPanelProps) {
196+
const isApp = hasAppHtml(toolCallInfo);
197+
198+
// For non-app tool calls, close immediately when isDestroying becomes true
199+
useEffect(() => {
200+
if (isDestroying && !isApp) {
201+
onCloseComplete?.();
202+
}
203+
}, [isDestroying, isApp, onCloseComplete]);
204+
169205
return (
170-
<div className={styles.toolCallInfoPanel}>
206+
<div
207+
className={styles.toolCallInfoPanel}
208+
style={isDestroying ? { opacity: 0.5, pointerEvents: "none" } : undefined}
209+
>
171210
<div className={styles.inputInfoPanel}>
172211
<h2>
173212
<span>{toolCallInfo.serverInfo.name}</span>
174213
<span className={styles.toolName}>{toolCallInfo.tool.name}</span>
214+
{onRequestClose && !isDestroying && (
215+
<button
216+
className={styles.closeButton}
217+
onClick={onRequestClose}
218+
title="Close"
219+
>
220+
×
221+
</button>
222+
)}
175223
</h2>
176224
<JsonBlock value={toolCallInfo.input} />
177225
</div>
178226
<div className={styles.outputInfoPanel}>
179227
<ErrorBoundary>
180228
<Suspense fallback="Loading...">
181229
{
182-
hasAppHtml(toolCallInfo)
183-
? <AppIFramePanel toolCallInfo={toolCallInfo} />
230+
isApp
231+
? <AppIFramePanel
232+
toolCallInfo={toolCallInfo}
233+
isDestroying={isDestroying}
234+
onTeardownComplete={onCloseComplete}
235+
/>
184236
: <ToolResultPanel toolCallInfo={toolCallInfo} />
185237
}
186238
</Suspense>
@@ -202,9 +254,12 @@ function JsonBlock({ value }: { value: object }) {
202254

203255
interface AppIFramePanelProps {
204256
toolCallInfo: Required<ToolCallInfo>;
257+
isDestroying?: boolean;
258+
onTeardownComplete?: () => void;
205259
}
206-
function AppIFramePanel({ toolCallInfo }: AppIFramePanelProps) {
260+
function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppIFramePanelProps) {
207261
const iframeRef = useRef<HTMLIFrameElement | null>(null);
262+
const appBridgeRef = useRef<ReturnType<typeof newAppBridge> | null>(null);
208263

209264
useEffect(() => {
210265
const iframe = iframeRef.current!;
@@ -215,11 +270,34 @@ function AppIFramePanel({ toolCallInfo }: AppIFramePanelProps) {
215270
// `toolCallInfo`.
216271
if (firstTime) {
217272
const appBridge = newAppBridge(toolCallInfo.serverInfo, iframe);
273+
appBridgeRef.current = appBridge;
218274
initializeApp(iframe, appBridge, toolCallInfo);
219275
}
220276
});
221277
}, [toolCallInfo]);
222278

279+
// Graceful teardown: wait for guest to respond before unmounting
280+
// This follows the spec: "Host SHOULD wait for a response before tearing
281+
// down the resource (to prevent data loss)."
282+
useEffect(() => {
283+
if (!isDestroying) return;
284+
285+
if (!appBridgeRef.current) {
286+
// Bridge not ready yet (e.g., user closed before iframe loaded)
287+
onTeardownComplete?.();
288+
return;
289+
}
290+
291+
log.info("Sending teardown notification to MCP App");
292+
appBridgeRef.current.sendResourceTeardown({})
293+
.catch((err) => {
294+
log.warn("Teardown request failed (app may have already closed):", err);
295+
})
296+
.finally(() => {
297+
onTeardownComplete?.();
298+
});
299+
}, [isDestroying, onTeardownComplete]);
300+
223301
return (
224302
<div className={styles.appIframePanel}>
225303
<iframe ref={iframeRef} />

examples/basic-server-react/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"@modelcontextprotocol/sdk": "^1.22.0",
1616
"react": "^19.2.0",
1717
"react-dom": "^19.2.0",
18-
"zod": "^3.25.0"
18+
"zod": "^4.1.13"
1919
},
2020
"devDependencies": {
2121
"@types/cors": "^2.8.19",
Lines changed: 12 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,18 @@
11
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2-
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
2+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
33
import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
4-
import cors from "cors";
5-
import express, { type Request, type Response } from "express";
64
import fs from "node:fs/promises";
75
import path from "node:path";
86
import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "../../dist/src/app";
7+
import { startServer } from "../shared/server-utils.js";
98

10-
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3001;
119
const DIST_DIR = path.join(import.meta.dirname, "dist");
1210

13-
1411
const server = new McpServer({
1512
name: "Basic MCP App Server (React-based)",
1613
version: "1.0.0",
1714
});
1815

19-
2016
// MCP Apps require two-part registration: a tool (what the LLM calls) and a
2117
// resource (the UI it renders). The `_meta` field on the tool links to the
2218
// resource URI, telling hosts which UI to display when the tool executes.
@@ -57,49 +53,16 @@ const server = new McpServer({
5753
);
5854
}
5955

60-
61-
const app = express();
62-
app.use(cors());
63-
app.use(express.json());
64-
65-
app.post("/mcp", async (req: Request, res: Response) => {
66-
try {
67-
const transport = new StreamableHTTPServerTransport({
68-
sessionIdGenerator: undefined,
69-
enableJsonResponse: true,
70-
});
71-
res.on("close", () => { transport.close(); });
72-
73-
await server.connect(transport);
74-
75-
await transport.handleRequest(req, res, req.body);
76-
} catch (error) {
77-
console.error("Error handling MCP request:", error);
78-
if (!res.headersSent) {
79-
res.status(500).json({
80-
jsonrpc: "2.0",
81-
error: { code: -32603, message: "Internal server error" },
82-
id: null,
83-
});
84-
}
56+
async function main() {
57+
if (process.argv.includes("--stdio")) {
58+
await server.connect(new StdioServerTransport());
59+
} else {
60+
const port = parseInt(process.env.PORT ?? "3101", 10);
61+
await startServer(server, { port, name: "Basic MCP App Server (React-based)" });
8562
}
86-
});
87-
88-
const httpServer = app.listen(PORT, (err) => {
89-
if (err) {
90-
console.error("Error starting server:", err);
91-
process.exit(1);
92-
}
93-
console.log(`Server listening on http://localhost:${PORT}/mcp`);
94-
});
95-
96-
function shutdown() {
97-
console.log("\nShutting down...");
98-
httpServer.close(() => {
99-
console.log("Server closed");
100-
process.exit(0);
101-
});
10263
}
10364

104-
process.on("SIGINT", shutdown);
105-
process.on("SIGTERM", shutdown);
65+
main().catch((e) => {
66+
console.error(e);
67+
process.exit(1);
68+
});

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/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"dependencies": {
1414
"@modelcontextprotocol/ext-apps": "../..",
1515
"@modelcontextprotocol/sdk": "^1.22.0",
16-
"zod": "^3.25.0"
16+
"zod": "^4.1.13"
1717
},
1818
"devDependencies": {
1919
"@types/cors": "^2.8.19",

0 commit comments

Comments
 (0)