From a94021002725bfdf0a1fa9f13ea9b754ca4f7cec Mon Sep 17 00:00:00 2001 From: Case Prince Date: Mon, 3 Aug 2026 22:48:29 -0400 Subject: [PATCH] spec(draft): add split display mode Add a fourth McpUiDisplayMode, "split": the View is displayed in a persistent, non-overlapping region while the host's primary conversational interface remains visible and interactive. This keeps interactive Views (spreadsheets, maps, dashboards) referenceable as the conversation scrolls on, instead of losing them off-screen. - spec.types.ts + draft spec: define "split" semantics and non-goals - regenerate Zod/JSON schemas via `npm run generate:schemas` - debug-server: add a "Split" display-mode control - basic-host: advertise "split" and render the View in a resizable, docked region without remounting the iframe, so View state survives inline <-> split transitions - unit + E2E coverage for negotiation and transitions Prototype for #684; relates to #412 and #430. Co-Authored-By: Claude Fable 5 --- examples/basic-host/src/global.css | 6 ++ examples/basic-host/src/implementation.ts | 14 +++-- examples/basic-host/src/index.module.css | 48 +++++++++++++++ examples/basic-host/src/index.tsx | 44 ++++++++++++-- examples/debug-server/mcp-app.html | 1 + examples/debug-server/src/mcp-app.ts | 17 ++++-- specification/draft/apps.mdx | 19 ++++-- src/app-bridge.test.ts | 71 +++++++++++++++++++++++ src/generated/schema.json | 44 ++++++++++++++ src/generated/schema.ts | 7 ++- src/spec.types.ts | 2 +- tests/e2e/display-mode-split.spec.ts | 51 ++++++++++++++++ 12 files changed, 301 insertions(+), 23 deletions(-) create mode 100644 tests/e2e/display-mode-split.spec.ts diff --git a/examples/basic-host/src/global.css b/examples/basic-host/src/global.css index 74b57514c..f04157b30 100644 --- a/examples/basic-host/src/global.css +++ b/examples/basic-host/src/global.css @@ -38,3 +38,9 @@ html, body { code { font-size: 1em; } + +/* While a View is in split display mode, reserve the right-hand region for it + so the conversation column and the split View never overlap. */ +body:has([data-display-mode="split"]) { + margin-right: var(--split-view-width, 40vw); +} diff --git a/examples/basic-host/src/implementation.ts b/examples/basic-host/src/implementation.ts index 31e36983e..7b5bd1007 100644 --- a/examples/basic-host/src/implementation.ts +++ b/examples/basic-host/src/implementation.ts @@ -1,4 +1,4 @@ -import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp, type McpUiResourcePermissions, buildAllowAttribute, type McpUiUpdateModelContextRequest, type McpUiMessageRequest } from "@modelcontextprotocol/ext-apps/app-bridge"; +import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiDisplayMode, type McpUiResourceCsp, type McpUiResourcePermissions, buildAllowAttribute, type McpUiUpdateModelContextRequest, type McpUiMessageRequest } from "@modelcontextprotocol/ext-apps/app-bridge"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; @@ -262,15 +262,19 @@ function hookInitializedCallback(appBridge: AppBridge): Promise { export type ModelContext = McpUiUpdateModelContextRequest["params"]; export type AppMessage = McpUiMessageRequest["params"]; +/** Display modes this host supports and advertises to apps. */ +const HOST_AVAILABLE_DISPLAY_MODES = ["inline", "fullscreen", "split"] as const satisfies readonly McpUiDisplayMode[]; +export type HostDisplayMode = (typeof HOST_AVAILABLE_DISPLAY_MODES)[number]; + export interface AppBridgeCallbacks { onContextUpdate?: (context: ModelContext | null) => void; onMessage?: (message: AppMessage) => void; - onDisplayModeChange?: (mode: "inline" | "fullscreen") => void; + onDisplayModeChange?: (mode: HostDisplayMode) => void; } export interface AppBridgeOptions { containerDimensions?: { maxHeight?: number; width?: number } | { height: number; width?: number }; - displayMode?: "inline" | "fullscreen"; + displayMode?: HostDisplayMode; } export function newAppBridge( @@ -296,7 +300,7 @@ export function newAppBridge( }, containerDimensions: options?.containerDimensions ?? { maxHeight: 6000 }, displayMode: options?.displayMode ?? "inline", - availableDisplayModes: ["inline", "fullscreen"], + availableDisplayModes: [...HOST_AVAILABLE_DISPLAY_MODES], }, }); @@ -395,7 +399,7 @@ export function newAppBridge( // Handle display mode change requests from the app appBridge.onrequestdisplaymode = async (params) => { log.info("Display mode request from MCP App:", params); - const newMode = params.mode === "fullscreen" ? "fullscreen" : "inline"; + const newMode = HOST_AVAILABLE_DISPLAY_MODES.find((m) => m === params.mode) ?? "inline"; // Update host context and notify the app appBridge.sendHostContextChange({ displayMode: newMode, diff --git a/examples/basic-host/src/index.module.css b/examples/basic-host/src/index.module.css index 0bd09dd05..3543a3eed 100644 --- a/examples/basic-host/src/index.module.css +++ b/examples/basic-host/src/index.module.css @@ -183,6 +183,54 @@ border-radius: 0; } } + + /* Persistent, non-overlapping region docked to the right; the host's + conversation column shifts aside via the body:has() rule in global.css + and stays visible and interactive. */ + &.split { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: var(--split-view-width, 40vw); + z-index: 900; + margin: 0; + padding: 1rem; + max-width: none; + background: var(--color-bg); + border: none; + border-left: 1px solid var(--color-border); + border-radius: 0; + display: flex; + flex-direction: column; + + /* The split region is dedicated to the View */ + .collapsiblePanel { + display: none; + } + + iframe { + flex: 1; + height: 100%; + border: none; + border-radius: 0; + } + } +} + +.splitResizeHandle { + position: absolute; + top: 0; + left: -3px; + width: 6px; + height: 100%; + cursor: col-resize; + touch-action: none; + user-select: none; + + &:hover { + background: var(--color-primary); + } } .appToolbar { diff --git a/examples/basic-host/src/index.tsx b/examples/basic-host/src/index.tsx index 3d488a792..e6faf5892 100644 --- a/examples/basic-host/src/index.tsx +++ b/examples/basic-host/src/index.tsx @@ -2,7 +2,7 @@ import { getToolUiResourceUri, McpUiToolMetaSchema } from "@modelcontextprotocol import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { Component, type ErrorInfo, type ReactNode, StrictMode, Suspense, use, useEffect, useMemo, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; -import { callTool, connectToServer, hasAppHtml, initializeApp, loadSandboxProxy, log, newAppBridge, type ServerInfo, type ToolCallInfo, type ModelContext, type AppMessage } from "./implementation"; +import { callTool, connectToServer, hasAppHtml, initializeApp, loadSandboxProxy, log, newAppBridge, type ServerInfo, type ToolCallInfo, type ModelContext, type AppMessage, type HostDisplayMode } from "./implementation"; import { getTheme, toggleTheme, onThemeChange, type Theme } from "./theme"; import styles from "./index.module.css"; @@ -417,6 +417,34 @@ function CollapsiblePanel({ icon, label, content, badge, defaultExpanded = false } +// Keep the split region within sensible bounds: wide enough to be useful, +// narrow enough that the conversation column stays usable. +function setSplitViewWidth(clientX: number) { + const width = Math.min( + Math.max(window.innerWidth - clientX, 280), + Math.max(window.innerWidth - 320, 280), + ); + document.documentElement.style.setProperty("--split-view-width", `${width}px`); +} + +function SplitResizeHandle() { + return ( +
{ + e.preventDefault(); + e.currentTarget.setPointerCapture(e.pointerId); + }} + onPointerMove={(e) => { + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + setSplitViewWidth(e.clientX); + } + }} + /> + ); +} + interface AppIFramePanelProps { toolCallInfo: Required; isDestroying?: boolean; @@ -427,7 +455,7 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI const appBridgeRef = useRef | null>(null); const [modelContext, setModelContext] = useState(null); const [messages, setMessages] = useState([]); - const [displayMode, setDisplayMode] = useState<"inline" | "fullscreen">("inline"); + const [displayMode, setDisplayMode] = useState("inline"); useEffect(() => { const iframe = iframeRef.current!; @@ -510,12 +538,16 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI }; const messagesText = messages.map(formatMessage).join("\n\n"); - const panelClassName = displayMode === "fullscreen" - ? `${styles.appIframePanel} ${styles.fullscreen}` - : styles.appIframePanel; + // Presentation only: the iframe node stays mounted across mode changes, + // so View state survives inline <-> split transitions. + const panelClassName = + displayMode === "inline" + ? styles.appIframePanel + : `${styles.appIframePanel} ${styles[displayMode]}`; return ( -
+
+ {displayMode === "split" && }