fix iframe resizing (#19)

* reshuffle build / start scripts

* fix resize logic

* prettier

* extract RESOURCE_URI_META_KEY constant

* Update message-transport.ts

* Update package.json
This commit is contained in:
Olivier Chafik 2025-11-25 02:39:33 +00:00 • committed by GitHub
commit 3df49c38dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 38 additions and 100 deletions

View file

@ -1,30 +0,0 @@
#!/usr/bin/env tsx
import { execSync } from "child_process";
import * as esbuild from "esbuild";
// Run TypeScript compiler for type declarations
execSync("tsc", { stdio: "inherit" });
const isDevelopment = process.env.NODE_ENV === "development";
// Build all JavaScript/TypeScript files
async function buildJs(
entrypoint: string,
opts: { outdir?: string; target?: "browser" | "node" } = {},
) {
return esbuild.build({
entryPoints: [entrypoint],
outdir: opts.outdir ?? "dist",
platform: opts.target === "node" ? "node" : "browser",
format: "esm",
bundle: true,
minify: !isDevelopment,
sourcemap: isDevelopment ? "inline" : false,
});
}
await Promise.all([
buildJs("src/app.ts", { outdir: "dist/src" }),
buildJs("src/app-bridge.ts", { outdir: "dist/src" }),
buildJs("src/react/index.tsx", { outdir: "dist/src/react" }),
]);

View file

@ -4,34 +4,30 @@
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start:server": "tsx server.ts", "start": "NODE_ENV=development npm run build && concurrently 'npm run start:server'",
"start:mcp-server": "cd ../simple-server && npm install && npm run start", "start:server": "bun server.ts",
"build": "concurrently 'INPUT=example-host-vanilla.html vite build' 'INPUT=example-host-react.html vite build' 'INPUT=sandbox.html vite build'", "build": "concurrently 'INPUT=example-host-vanilla.html vite build' 'INPUT=example-host-react.html vite build' 'INPUT=sandbox.html vite build'"
"server": "bun server.ts",
"start": "NODE_ENV=development npm run build && concurrently 'npm run start:server' 'npm run start:mcp-server'"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/ext-apps": "../..", "@modelcontextprotocol/ext-apps": "../..",
"@modelcontextprotocol/sdk": "^1.22.0", "@modelcontextprotocol/sdk": "^1.22.0",
"react-dom": "^19.2.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0",
"zod": "^3.25.0" "zod": "^3.25.0"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/react-dom": "^19.2.2",
"@types/react": "^19.2.2", "@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"bun": "^1.3.2",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"esbuild": "~0.19.10",
"express": "^5.1.0", "express": "^5.1.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"tsx": "^4.20.6",
"typescript": "^5.9.3",
"vite-plugin-singlefile": "^2.3.0",
"vite": "^6.0.0", "vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0",
"vitest": "^3.2.4" "vitest": "^3.2.4"
} }
} }

View file

@ -4,9 +4,9 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "NODE_ENV=development npm run build && npm run server",
"build": "concurrently 'INPUT=ui-raw.html vite build' 'INPUT=ui-vanilla.html vite build' 'INPUT=ui-react.html vite build'", "build": "concurrently 'INPUT=ui-raw.html vite build' 'INPUT=ui-vanilla.html vite build' 'INPUT=ui-react.html vite build'",
"server": "bun server.ts", "server": "bun server.ts"
"start": "npm run build && npm run server"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/ext-apps": "../..", "@modelcontextprotocol/ext-apps": "../..",

View file

@ -14,6 +14,7 @@ import cors from "cors";
import path from "node:path"; import path from "node:path";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { RESOURCE_URI_META_KEY } from "@modelcontextprotocol/ext-apps";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@ -81,7 +82,7 @@ const getServer = async () => {
message: z.string().describe("Message to display"), message: z.string().describe("Message to display"),
}, },
_meta: { _meta: {
"ui/resourceUri": rawResource.uri, [RESOURCE_URI_META_KEY]: rawResource.uri,
}, },
}, },
async ({ message }): Promise<CallToolResult> => ({ async ({ message }): Promise<CallToolResult> => ({
@ -112,7 +113,7 @@ const getServer = async () => {
message: z.string().describe("Message to display"), message: z.string().describe("Message to display"),
}, },
_meta: { _meta: {
"ui/resourceUri": vanillaResource.uri, [RESOURCE_URI_META_KEY]: vanillaResource.uri,
}, },
}, },
async ({ message }): Promise<CallToolResult> => ({ async ({ message }): Promise<CallToolResult> => ({
@ -143,7 +144,7 @@ const getServer = async () => {
message: z.string().describe("Message to display"), message: z.string().describe("Message to display"),
}, },
_meta: { _meta: {
"ui/resourceUri": reactResource.uri, [RESOURCE_URI_META_KEY]: reactResource.uri,
}, },
}, },
async ({ message }): Promise<CallToolResult> => ({ async ({ message }): Promise<CallToolResult> => ({

View file

@ -131,19 +131,11 @@ window.addEventListener("load", async () => {
}); });
new ResizeObserver(() => { new ResizeObserver(() => {
const { body, documentElement: html } = document; const rect = (
document.body.parentElement ?? document.body
const bodyStyle = getComputedStyle(body); ).getBoundingClientRect();
const htmlStyle = getComputedStyle(html); const width = Math.ceil(rect.width);
const height = Math.ceil(rect.height);
const width = body.scrollWidth;
const height =
body.scrollHeight +
(parseFloat(bodyStyle.borderTop) || 0) +
(parseFloat(bodyStyle.borderBottom) || 0) +
(parseFloat(htmlStyle.borderTop) || 0) +
(parseFloat(htmlStyle.borderBottom) || 0);
app.sendNotification<McpUiSizeChangeNotification>({ app.sendNotification<McpUiSizeChangeNotification>({
method: "ui/notifications/size-change", method: "ui/notifications/size-change",
params: { width, height }, params: { width, height },

View file

@ -48,13 +48,6 @@ window.addEventListener("load", async () => {
appendText(`Host context changed: ${JSON.stringify(params)}`); appendText(`Host context changed: ${JSON.stringify(params)}`);
}; };
document.body.addEventListener("resize", () => {
app.sendSizeChange({
width: document.body.scrollWidth,
height: document.body.scrollHeight,
});
});
root.appendChild( root.appendChild(
Object.assign(document.createElement("button"), { Object.assign(document.createElement("button"), {
textContent: "Get Weather (Tool)", textContent: "Get Weather (Tool)",

View file

@ -23,7 +23,9 @@
"dist" "dist"
], ],
"scripts": { "scripts": {
"start": "cd examples/simple-host && npm i && npm start", "start:example-host": "cd examples/simple-host && npm --registry=https://registry.npmjs.org/ i && npm start",
"start:example-mcp-server": "cd examples//simple-server && npm --registry=https://registry.npmjs.org/ install && npm start",
"start": "NODE_ENV=development npm run build && concurrently 'npm run start:example-host' 'npm run start:example-mcp-server'",
"build": "bun build.bun.ts", "build": "bun build.bun.ts",
"prepare": "npm run build", "prepare": "npm run build",
"prettier:base-cmd": "prettier -u --ignore-path ./.gitignore --ignore-path ./.prettierignore", "prettier:base-cmd": "prettier -u --ignore-path ./.gitignore --ignore-path ./.prettierignore",
@ -36,15 +38,16 @@
"@types/react-dom": "^19.2.2", "@types/react-dom": "^19.2.2",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"esbuild": "^0.25.12",
"express": "^5.1.0", "express": "^5.1.0",
"prettier": "^3.6.2" "prettier": "^3.6.2",
"typescript": "^5.9.3"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.22.0", "@modelcontextprotocol/sdk": "^1.22.0",
"bun": "^1.3.2", "bun": "^1.3.2",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"typescript": "5.7.2",
"zod": "^3.25" "zod": "^3.25"
} }
} }

View file

@ -316,7 +316,7 @@ function sendRequest(method: string, params: any) {
if (event.data?.id === id) { if (event.data?.id === id) {
window.removeEventListener('message', listener); window.removeEventListener('message', listener);
if (event.data?.result) { if (event.data?.result) {
resolve(true); resolve(event.data?.result);
} else if (event.data?.error) { } else if (event.data?.error) {
reject(new Error(event.data.error)); reject(new Error(event.data.error));
} }

View file

@ -185,13 +185,6 @@ export class AppBridge extends Protocol<Request, Notification, Result> {
}; };
} }
sendSizeChange(params: McpUiSizeChangeNotification["params"]) {
return this.notification(<McpUiSizeChangeNotification>{
method: "ui/notifications/size-change",
params,
});
}
sendToolInput(params: McpUiToolInputNotification["params"]) { sendToolInput(params: McpUiToolInputNotification["params"]) {
return this.notification(<McpUiToolInputNotification>{ return this.notification(<McpUiToolInputNotification>{
method: "ui/notifications/tool-input", method: "ui/notifications/tool-input",

View file

@ -41,6 +41,8 @@ import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
export { PostMessageTransport } from "./message-transport.js"; export { PostMessageTransport } from "./message-transport.js";
export * from "./types"; export * from "./types";
export const RESOURCE_URI_META_KEY = "ui/resourceUri";
type AppOptions = ProtocolOptions & { type AppOptions = ProtocolOptions & {
autoResize?: boolean; autoResize?: boolean;
}; };
@ -166,30 +168,20 @@ export class App extends Protocol<Request, Notification, Result> {
} }
setupSizeChangeNotifications() { setupSizeChangeNotifications() {
let scheduled = false;
const sendBodySizeChange = () => { const sendBodySizeChange = () => {
let rafId: number | null = null; if (scheduled) {
return;
// Debounce using requestAnimationFrame to avoid duplicate messages
// when both documentElement and body fire resize events
if (rafId !== null) {
cancelAnimationFrame(rafId);
} }
rafId = requestAnimationFrame(() => { scheduled = true;
const { body, documentElement: html } = document; requestAnimationFrame(() => {
scheduled = false;
const bodyStyle = getComputedStyle(body); const rect = (
const htmlStyle = getComputedStyle(html); document.body.parentElement ?? document.body
).getBoundingClientRect();
const width = body.scrollWidth; const width = Math.ceil(rect.width);
const height = const height = Math.ceil(rect.height);
body.scrollHeight +
(parseFloat(bodyStyle.borderTop) || 0) +
(parseFloat(bodyStyle.borderBottom) || 0) +
(parseFloat(htmlStyle.borderTop) || 0) +
(parseFloat(htmlStyle.borderBottom) || 0);
this.sendSizeChange({ width, height }); this.sendSizeChange({ width, height });
rafId = null;
}); });
}; };

View file

@ -3,12 +3,10 @@ import {
JSONRPCMessageSchema, JSONRPCMessageSchema,
MessageExtraInfo, MessageExtraInfo,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
import { import {
Transport, Transport,
TransportSendOptions, TransportSendOptions,
} from "@modelcontextprotocol/sdk/shared/transport.js"; } from "@modelcontextprotocol/sdk/shared/transport.js";
import { z } from "zod";
export class PostMessageTransport implements Transport { export class PostMessageTransport implements Transport {
private messageListener: ( private messageListener: (