diff --git a/examples/customer-segmentation-server/mcp-app.html b/examples/customer-segmentation-server/mcp-app.html index 33a684a9..d4efe236 100644 --- a/examples/customer-segmentation-server/mcp-app.html +++ b/examples/customer-segmentation-server/mcp-app.html @@ -10,6 +10,11 @@

Customer Segmentation

+
diff --git a/examples/customer-segmentation-server/src/mcp-app.css b/examples/customer-segmentation-server/src/mcp-app.css index bfb5e179..cad5725a 100644 --- a/examples/customer-segmentation-server/src/mcp-app.css +++ b/examples/customer-segmentation-server/src/mcp-app.css @@ -2,6 +2,12 @@ :root { color-scheme: light dark; + /* Safe area insets (set by JS from host context) */ + --safe-area-inset-top: 0px; + --safe-area-inset-right: 0px; + --safe-area-inset-bottom: 0px; + --safe-area-inset-left: 0px; + /* Font families */ --font-sans: system-ui, -apple-system, sans-serif; @@ -61,6 +67,17 @@ html, body { overflow: hidden; } +/* Fullscreen mode - no border radius, fill container with safe area padding */ +[data-display-mode="fullscreen"] .main { + border-radius: 0; + height: 100vh; + max-height: 100vh; + padding-top: var(--safe-area-inset-top); + padding-right: var(--safe-area-inset-right); + padding-bottom: var(--safe-area-inset-bottom); + padding-left: var(--safe-area-inset-left); +} + /* Header - ~40px */ .header { display: flex; @@ -78,6 +95,42 @@ html, body { flex-shrink: 0; } +.fullscreen-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: var(--border-width-regular) solid var(--color-border-primary); + border-radius: var(--border-radius-sm); + background: var(--color-background-secondary); + color: var(--color-text-secondary); + cursor: pointer; + transition: all 0.15s ease; +} + +.fullscreen-btn:hover { + background: var(--color-background-tertiary); + color: var(--color-text-primary); + box-shadow: var(--shadow-sm); +} + +.fullscreen-btn:focus { + outline: 2px solid var(--color-ring-info); + outline-offset: 1px; +} + +.fullscreen-btn svg { + width: 16px; + height: 16px; +} + +/* Hide fullscreen button when already in fullscreen mode */ +[data-display-mode="fullscreen"] .fullscreen-btn { + display: none; +} + .header-controls { display: flex; align-items: center; diff --git a/examples/customer-segmentation-server/src/mcp-app.ts b/examples/customer-segmentation-server/src/mcp-app.ts index 5feb9100..5082a4c7 100644 --- a/examples/customer-segmentation-server/src/mcp-app.ts +++ b/examples/customer-segmentation-server/src/mcp-app.ts @@ -21,6 +21,30 @@ const log = { error: console.error.bind(console, "[APP]"), }; +/** + * Apply safe area insets as CSS custom properties on the document root. + */ +function applySafeAreaInsets(insets: { + top?: number; + right?: number; + bottom?: number; + left?: number; +}) { + const root = document.documentElement; + if (insets.top !== undefined) { + root.style.setProperty("--safe-area-inset-top", `${insets.top}px`); + } + if (insets.right !== undefined) { + root.style.setProperty("--safe-area-inset-right", `${insets.right}px`); + } + if (insets.bottom !== undefined) { + root.style.setProperty("--safe-area-inset-bottom", `${insets.bottom}px`); + } + if (insets.left !== undefined) { + root.style.setProperty("--safe-area-inset-left", `${insets.left}px`); + } +} + // DOM element references const xAxisSelect = document.getElementById("x-axis") as HTMLSelectElement; const yAxisSelect = document.getElementById("y-axis") as HTMLSelectElement; @@ -32,6 +56,9 @@ const chartCanvas = document.getElementById( ) as HTMLCanvasElement; const legendContainer = document.getElementById("legend")!; const detailPanel = document.getElementById("detail-panel")!; +const fullscreenBtn = document.getElementById( + "fullscreen-btn", +) as HTMLButtonElement; // App state interface AppState { @@ -364,8 +391,11 @@ function resetDetailPanel(): void { 'Hover over a point to see details'; } -// Create app instance -const app = new App({ name: "Customer Segmentation", version: "1.0.0" }); +// Create app instance with fullscreen support +const app = new App( + { name: "Customer Segmentation", version: "1.0.0" }, + { availableDisplayModes: ["inline", "fullscreen"] }, +); // Fetch data from server async function fetchData(): Promise { @@ -419,6 +449,16 @@ sizeMetricSelect.addEventListener("change", () => { updateChart(); }); +// Fullscreen toggle +fullscreenBtn.addEventListener("click", async () => { + try { + const result = await app.requestDisplayMode({ mode: "fullscreen" }); + log.info("Display mode changed to:", result.mode); + } catch (error) { + log.error("Failed to change display mode:", error); + } +}); + // Clear selection when clicking outside chart document.addEventListener("click", (e) => { if (!(e.target as HTMLElement).closest(".chart-section")) { @@ -459,6 +499,33 @@ app.onhostcontextchanged = (params) => { if (params.styles?.css?.fonts) { applyHostFonts(params.styles.css.fonts); } + if (params.displayMode) { + document.documentElement.setAttribute( + "data-display-mode", + params.displayMode, + ); + } + if (params.safeAreaInsets) { + applySafeAreaInsets(params.safeAreaInsets); + } + // Update container height based on containerDimensions from host + // This ensures the chart resizes correctly during transitions + if (params.containerDimensions) { + const mainEl = document.querySelector(".main") as HTMLElement | null; + if (mainEl) { + if ("height" in params.containerDimensions) { + // If height is fixed, take up all the height + mainEl.style.height = "100vh"; + } else if ("maxHeight" in params.containerDimensions) { + // If height is variable, let the rest of the css determine the height + mainEl.style.height = ""; + } + // Resize chart after container dimensions change + if (state.chart) { + state.chart.resize(); + } + } + } // Recreate chart to pick up new colors if (state.chart && (params.theme || params.styles?.variables)) { state.chart.destroy(); @@ -478,6 +545,19 @@ app.connect().then(() => { if (ctx?.styles?.css?.fonts) { applyHostFonts(ctx.styles.css.fonts); } + if (ctx?.displayMode) { + document.documentElement.setAttribute("data-display-mode", ctx.displayMode); + } + if (ctx?.safeAreaInsets) { + applySafeAreaInsets(ctx.safeAreaInsets); + } + // Apply initial container dimensions + if (ctx?.containerDimensions) { + const mainEl = document.querySelector(".main") as HTMLElement | null; + if (mainEl && "height" in ctx.containerDimensions) { + mainEl.style.height = `${ctx.containerDimensions.height}px`; + } + } }); // Fetch data after connection diff --git a/package-lock.json b/package-lock.json index 00ad1e77..7c81e8d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -306,7 +306,7 @@ "version": "1.0.0", "dependencies": { "@modelcontextprotocol/ext-apps": "../..", - "@modelcontextprotocol/sdk": "^1.22.0", + "@modelcontextprotocol/sdk": "^1.24.0", "react": "^19.2.0", "react-dom": "^19.2.0", "zod": "^4.1.13" @@ -318,7 +318,6 @@ "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^4.3.4", - "bun": "^1.3.2", "concurrently": "^9.2.1", "cors": "^2.8.5", "express": "^5.1.0", @@ -534,7 +533,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2140,7 +2138,6 @@ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2165,7 +2162,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2666,7 +2662,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2685,40 +2680,6 @@ "resolved": "examples/budget-allocator-server", "link": true }, - "node_modules/bun": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/bun/-/bun-1.3.5.tgz", - "integrity": "sha512-c1YHIGUfgvYPJmLug5QiLzNWlX2Dg7X/67JWu1Va+AmMXNXzC/KQn2lgQ7rD+n1u1UqDpJMowVGGxTNpbPydNw==", - "cpu": [ - "arm64", - "x64" - ], - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "os": [ - "darwin", - "linux", - "win32" - ], - "bin": { - "bun": "bin/bun.exe", - "bunx": "bin/bunx.exe" - }, - "optionalDependencies": { - "@oven/bun-darwin-aarch64": "1.3.5", - "@oven/bun-darwin-x64": "1.3.5", - "@oven/bun-darwin-x64-baseline": "1.3.5", - "@oven/bun-linux-aarch64": "1.3.5", - "@oven/bun-linux-aarch64-musl": "1.3.5", - "@oven/bun-linux-x64": "1.3.5", - "@oven/bun-linux-x64-baseline": "1.3.5", - "@oven/bun-linux-x64-musl": "1.3.5", - "@oven/bun-linux-x64-musl-baseline": "1.3.5", - "@oven/bun-windows-x64": "1.3.5", - "@oven/bun-windows-x64-baseline": "1.3.5" - } - }, "node_modules/bun-types": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.3.tgz", @@ -3416,7 +3377,6 @@ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -3864,7 +3824,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -5401,7 +5360,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5500,7 +5458,6 @@ "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -6271,7 +6228,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7021,7 +6977,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7115,7 +7070,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -7249,7 +7203,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7502,7 +7455,6 @@ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -7547,7 +7499,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/generated/schema.json b/src/generated/schema.json index 995eb4dd..b6b87974 100644 --- a/src/generated/schema.json +++ b/src/generated/schema.json @@ -24,6 +24,27 @@ } }, "additionalProperties": false + }, + "availableDisplayModes": { + "description": "Display modes the app supports.", + "type": "array", + "items": { + "description": "Display mode for UI presentation.", + "anyOf": [ + { + "type": "string", + "const": "inline" + }, + { + "type": "string", + "const": "fullscreen" + }, + { + "type": "string", + "const": "pip" + } + ] + } } }, "additionalProperties": false @@ -629,7 +650,21 @@ "description": "Display modes the host supports.", "type": "array", "items": { - "type": "string" + "description": "Display mode for UI presentation.", + "anyOf": [ + { + "type": "string", + "const": "inline" + }, + { + "type": "string", + "const": "fullscreen" + }, + { + "type": "string", + "const": "pip" + } + ] } }, "containerDimensions": { @@ -1300,7 +1335,21 @@ "description": "Display modes the host supports.", "type": "array", "items": { - "type": "string" + "description": "Display mode for UI presentation.", + "anyOf": [ + { + "type": "string", + "const": "inline" + }, + { + "type": "string", + "const": "fullscreen" + }, + { + "type": "string", + "const": "pip" + } + ] } }, "containerDimensions": { @@ -1867,6 +1916,27 @@ } }, "additionalProperties": false + }, + "availableDisplayModes": { + "description": "Display modes the app supports.", + "type": "array", + "items": { + "description": "Display mode for UI presentation.", + "anyOf": [ + { + "type": "string", + "const": "inline" + }, + { + "type": "string", + "const": "fullscreen" + }, + { + "type": "string", + "const": "pip" + } + ] + } } }, "additionalProperties": false @@ -2508,7 +2578,21 @@ "description": "Display modes the host supports.", "type": "array", "items": { - "type": "string" + "description": "Display mode for UI presentation.", + "anyOf": [ + { + "type": "string", + "const": "inline" + }, + { + "type": "string", + "const": "fullscreen" + }, + { + "type": "string", + "const": "pip" + } + ] } }, "containerDimensions": { diff --git a/src/generated/schema.ts b/src/generated/schema.ts index bc415e51..d6be8583 100644 --- a/src/generated/schema.ts +++ b/src/generated/schema.ts @@ -396,6 +396,11 @@ export const McpUiAppCapabilitiesSchema = z.object({ }) .optional() .describe("App exposes MCP-style tools that the host can call."), + /** @description Display modes the app supports. */ + availableDisplayModes: z + .array(McpUiDisplayModeSchema) + .optional() + .describe("Display modes the app supports."), }); /** @@ -561,7 +566,7 @@ export const McpUiHostContextSchema = z ), /** @description Display modes the host supports. */ availableDisplayModes: z - .array(z.string()) + .array(McpUiDisplayModeSchema) .optional() .describe("Display modes the host supports."), /** diff --git a/src/spec.types.ts b/src/spec.types.ts index 5b7135ba..b6363408 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -324,7 +324,7 @@ export interface McpUiHostContext { /** @description How the UI is currently displayed. */ displayMode?: McpUiDisplayMode; /** @description Display modes the host supports. */ - availableDisplayModes?: string[]; + availableDisplayModes?: McpUiDisplayMode[]; /** * @description Container dimensions. Represents the dimensions of the iframe or other * container holding the app. Specify either width or maxWidth, and either height or maxHeight. @@ -442,6 +442,8 @@ export interface McpUiAppCapabilities { /** @description App supports tools/list_changed notifications. */ listChanged?: boolean; }; + /** @description Display modes the app supports. */ + availableDisplayModes?: McpUiDisplayMode[]; } /**