Skip to content

Commit a940210

Browse files
caseprinceclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 92f46a5 commit a940210

12 files changed

Lines changed: 301 additions & 23 deletions

File tree

examples/basic-host/src/global.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,9 @@ html, body {
3838
code {
3939
font-size: 1em;
4040
}
41+
42+
/* While a View is in split display mode, reserve the right-hand region for it
43+
so the conversation column and the split View never overlap. */
44+
body:has([data-display-mode="split"]) {
45+
margin-right: var(--split-view-width, 40vw);
46+
}

examples/basic-host/src/implementation.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { RESOURCE_MIME_TYPE, getToolUiResourceUri, type McpUiSandboxProxyReadyNotification, AppBridge, PostMessageTransport, type McpUiResourceCsp, type McpUiResourcePermissions, buildAllowAttribute, type McpUiUpdateModelContextRequest, type McpUiMessageRequest } from "@modelcontextprotocol/ext-apps/app-bridge";
1+
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";
22
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
33
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
44
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
@@ -262,15 +262,19 @@ function hookInitializedCallback(appBridge: AppBridge): Promise<void> {
262262
export type ModelContext = McpUiUpdateModelContextRequest["params"];
263263
export type AppMessage = McpUiMessageRequest["params"];
264264

265+
/** Display modes this host supports and advertises to apps. */
266+
const HOST_AVAILABLE_DISPLAY_MODES = ["inline", "fullscreen", "split"] as const satisfies readonly McpUiDisplayMode[];
267+
export type HostDisplayMode = (typeof HOST_AVAILABLE_DISPLAY_MODES)[number];
268+
265269
export interface AppBridgeCallbacks {
266270
onContextUpdate?: (context: ModelContext | null) => void;
267271
onMessage?: (message: AppMessage) => void;
268-
onDisplayModeChange?: (mode: "inline" | "fullscreen") => void;
272+
onDisplayModeChange?: (mode: HostDisplayMode) => void;
269273
}
270274

271275
export interface AppBridgeOptions {
272276
containerDimensions?: { maxHeight?: number; width?: number } | { height: number; width?: number };
273-
displayMode?: "inline" | "fullscreen";
277+
displayMode?: HostDisplayMode;
274278
}
275279

276280
export function newAppBridge(
@@ -296,7 +300,7 @@ export function newAppBridge(
296300
},
297301
containerDimensions: options?.containerDimensions ?? { maxHeight: 6000 },
298302
displayMode: options?.displayMode ?? "inline",
299-
availableDisplayModes: ["inline", "fullscreen"],
303+
availableDisplayModes: [...HOST_AVAILABLE_DISPLAY_MODES],
300304
},
301305
});
302306

@@ -395,7 +399,7 @@ export function newAppBridge(
395399
// Handle display mode change requests from the app
396400
appBridge.onrequestdisplaymode = async (params) => {
397401
log.info("Display mode request from MCP App:", params);
398-
const newMode = params.mode === "fullscreen" ? "fullscreen" : "inline";
402+
const newMode = HOST_AVAILABLE_DISPLAY_MODES.find((m) => m === params.mode) ?? "inline";
399403
// Update host context and notify the app
400404
appBridge.sendHostContextChange({
401405
displayMode: newMode,

examples/basic-host/src/index.module.css

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,54 @@
183183
border-radius: 0;
184184
}
185185
}
186+
187+
/* Persistent, non-overlapping region docked to the right; the host's
188+
conversation column shifts aside via the body:has() rule in global.css
189+
and stays visible and interactive. */
190+
&.split {
191+
position: fixed;
192+
top: 0;
193+
right: 0;
194+
bottom: 0;
195+
width: var(--split-view-width, 40vw);
196+
z-index: 900;
197+
margin: 0;
198+
padding: 1rem;
199+
max-width: none;
200+
background: var(--color-bg);
201+
border: none;
202+
border-left: 1px solid var(--color-border);
203+
border-radius: 0;
204+
display: flex;
205+
flex-direction: column;
206+
207+
/* The split region is dedicated to the View */
208+
.collapsiblePanel {
209+
display: none;
210+
}
211+
212+
iframe {
213+
flex: 1;
214+
height: 100%;
215+
border: none;
216+
border-radius: 0;
217+
}
218+
}
219+
}
220+
221+
.splitResizeHandle {
222+
position: absolute;
223+
top: 0;
224+
left: -3px;
225+
width: 6px;
226+
height: 100%;
227+
cursor: col-resize;
228+
touch-action: none;
229+
user-select: none;
230+
231+
&:hover {
232+
background: var(--color-primary);
233+
}
186234
}
187235

188236
.appToolbar {

examples/basic-host/src/index.tsx

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { getToolUiResourceUri, McpUiToolMetaSchema } from "@modelcontextprotocol
22
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
33
import { Component, type ErrorInfo, type ReactNode, StrictMode, Suspense, use, useEffect, useMemo, useRef, useState } from "react";
44
import { createRoot } from "react-dom/client";
5-
import { callTool, connectToServer, hasAppHtml, initializeApp, loadSandboxProxy, log, newAppBridge, type ServerInfo, type ToolCallInfo, type ModelContext, type AppMessage } from "./implementation";
5+
import { callTool, connectToServer, hasAppHtml, initializeApp, loadSandboxProxy, log, newAppBridge, type ServerInfo, type ToolCallInfo, type ModelContext, type AppMessage, type HostDisplayMode } from "./implementation";
66
import { getTheme, toggleTheme, onThemeChange, type Theme } from "./theme";
77
import styles from "./index.module.css";
88

@@ -417,6 +417,34 @@ function CollapsiblePanel({ icon, label, content, badge, defaultExpanded = false
417417
}
418418

419419

420+
// Keep the split region within sensible bounds: wide enough to be useful,
421+
// narrow enough that the conversation column stays usable.
422+
function setSplitViewWidth(clientX: number) {
423+
const width = Math.min(
424+
Math.max(window.innerWidth - clientX, 280),
425+
Math.max(window.innerWidth - 320, 280),
426+
);
427+
document.documentElement.style.setProperty("--split-view-width", `${width}px`);
428+
}
429+
430+
function SplitResizeHandle() {
431+
return (
432+
<div
433+
className={styles.splitResizeHandle}
434+
title="Drag to resize"
435+
onPointerDown={(e) => {
436+
e.preventDefault();
437+
e.currentTarget.setPointerCapture(e.pointerId);
438+
}}
439+
onPointerMove={(e) => {
440+
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
441+
setSplitViewWidth(e.clientX);
442+
}
443+
}}
444+
/>
445+
);
446+
}
447+
420448
interface AppIFramePanelProps {
421449
toolCallInfo: Required<ToolCallInfo>;
422450
isDestroying?: boolean;
@@ -427,7 +455,7 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
427455
const appBridgeRef = useRef<ReturnType<typeof newAppBridge> | null>(null);
428456
const [modelContext, setModelContext] = useState<ModelContext | null>(null);
429457
const [messages, setMessages] = useState<AppMessage[]>([]);
430-
const [displayMode, setDisplayMode] = useState<"inline" | "fullscreen">("inline");
458+
const [displayMode, setDisplayMode] = useState<HostDisplayMode>("inline");
431459

432460
useEffect(() => {
433461
const iframe = iframeRef.current!;
@@ -510,12 +538,16 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
510538
};
511539
const messagesText = messages.map(formatMessage).join("\n\n");
512540

513-
const panelClassName = displayMode === "fullscreen"
514-
? `${styles.appIframePanel} ${styles.fullscreen}`
515-
: styles.appIframePanel;
541+
// Presentation only: the iframe node stays mounted across mode changes,
542+
// so View state survives inline <-> split transitions.
543+
const panelClassName =
544+
displayMode === "inline"
545+
? styles.appIframePanel
546+
: `${styles.appIframePanel} ${styles[displayMode]}`;
516547

517548
return (
518-
<div className={panelClassName}>
549+
<div className={panelClassName} data-display-mode={displayMode}>
550+
{displayMode === "split" && <SplitResizeHandle />}
519551
<iframe ref={iframeRef} />
520552
{messages.length > 0 && (
521553
<CollapsiblePanel

examples/debug-server/mcp-app.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ <h3>Display Mode</h3>
134134
<button id="display-inline-btn" class="btn-small">Inline</button>
135135
<button id="display-fullscreen-btn" class="btn-small">Fullscreen</button>
136136
<button id="display-pip-btn" class="btn-small">PiP</button>
137+
<button id="display-split-btn" class="btn-small">Split</button>
137138
</div>
138139
</div>
139140

examples/debug-server/src/mcp-app.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
*
44
* This app exercises every capability, callback, and result format combination.
55
*/
6-
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
6+
import {
7+
App,
8+
type McpUiDisplayMode,
9+
type McpUiHostContext,
10+
} from "@modelcontextprotocol/ext-apps";
711
import "./global.css";
812
import "./mcp-app.css";
913

@@ -99,6 +103,7 @@ const updateContextImageBtn = document.getElementById(
99103
const displayInlineBtn = document.getElementById("display-inline-btn")!;
100104
const displayFullscreenBtn = document.getElementById("display-fullscreen-btn")!;
101105
const displayPipBtn = document.getElementById("display-pip-btn")!;
106+
const displaySplitBtn = document.getElementById("display-split-btn")!;
102107

103108
const linkUrlEl = document.getElementById("link-url") as HTMLInputElement;
104109
const openLinkBtn = document.getElementById("open-link-btn")!;
@@ -347,7 +352,10 @@ function handleHostContextChanged(ctx: McpUiHostContext): void {
347352

348353
const app = new App(
349354
{ name: "Debug App", version: "1.0.0" },
350-
{ tools: { listChanged: true } }, // Declare tools capability for oncalltool/onlisttools
355+
{
356+
tools: { listChanged: true }, // Declare tools capability for oncalltool/onlisttools
357+
availableDisplayModes: ["inline", "fullscreen", "pip", "split"],
358+
},
351359
{ autoResize: false }, // We'll manage auto-resize ourselves for toggle demo
352360
);
353361

@@ -513,9 +521,7 @@ updateContextImageBtn.addEventListener("click", async () => {
513521
// Display Mode Actions
514522
// ============================================================================
515523

516-
async function requestDisplayMode(
517-
mode: "inline" | "fullscreen" | "pip",
518-
): Promise<void> {
524+
async function requestDisplayMode(mode: McpUiDisplayMode): Promise<void> {
519525
try {
520526
const result = await app.requestDisplayMode({ mode });
521527
logEvent("display-mode-result", { mode, result });
@@ -529,6 +535,7 @@ displayFullscreenBtn.addEventListener("click", () =>
529535
requestDisplayMode("fullscreen"),
530536
);
531537
displayPipBtn.addEventListener("click", () => requestDisplayMode("pip"));
538+
displaySplitBtn.addEventListener("click", () => requestDisplayMode("split"));
532539

533540
// ============================================================================
534541
// Link Action

specification/draft/apps.mdx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,7 @@ interface McpUiAppCapabilities {
559559
* Display modes the app supports. See Display Modes section for details.
560560
* @example ["inline", "fullscreen"]
561561
*/
562-
availableDisplayModes?: Array<"inline" | "fullscreen" | "pip">;
562+
availableDisplayModes?: Array<"inline" | "fullscreen" | "pip" | "split">;
563563
}
564564
```
565565

@@ -589,7 +589,7 @@ interface HostContext {
589589
};
590590
};
591591
/** How the View is currently displayed */
592-
displayMode?: "inline" | "fullscreen" | "pip";
592+
displayMode?: "inline" | "fullscreen" | "pip" | "split";
593593
/** Display modes the host supports */
594594
availableDisplayModes?: string[];
595595
/** Container dimensions for the iframe. Specify either width or maxWidth, and either height or maxHeight. */
@@ -805,12 +805,21 @@ Views using the SDK automatically send size-changed notifications via ResizeObse
805805
Views can be displayed in different modes depending on the host's capabilities and the view's declared support.
806806

807807
```typescript
808-
type McpUiDisplayMode = "inline" | "fullscreen" | "pip";
808+
type McpUiDisplayMode = "inline" | "fullscreen" | "pip" | "split";
809809
```
810810

811811
- **inline**: Default mode, embedded within the host's content flow
812812
- **fullscreen**: View takes over the full screen/window
813813
- **pip**: Picture-in-picture, floating overlay
814+
- **split**: View is displayed in a persistent, non-overlapping region while the host's primary conversational interface remains visible and interactive
815+
816+
#### Split Mode
817+
818+
In `split` mode, the View occupies a dedicated region of the host UI (e.g., a side panel) that does not overlap the host's primary conversational interface. Both remain visible and interactive at the same time, allowing users to keep referencing the View as the conversation continues.
819+
820+
- Host controls the orientation, placement, and dimensions of the split region, how (and whether) it can be resized, and how many split Views it permits at once.
821+
- Entering or leaving `split` mode SHOULD NOT inherently recreate the current View: it is a presentation change of the already-rendered View, and its state SHOULD be preserved across the transition.
822+
- This mode does not define reusing Views across separate tool calls; each tool call still renders a new View instance.
814823

815824
#### Declaring Support
816825

@@ -1177,7 +1186,7 @@ Host behavior:
11771186
id: 3,
11781187
method: "ui/request-display-mode",
11791188
params: {
1180-
mode: "inline" | "fullscreen" | "pip" // Requested display mode
1189+
mode: "inline" | "fullscreen" | "pip" | "split" // Requested display mode
11811190
}
11821191
}
11831192

@@ -1186,7 +1195,7 @@ Host behavior:
11861195
jsonrpc: "2.0",
11871196
id: 3,
11881197
result: {
1189-
mode: "inline" | "fullscreen" | "pip" // Actual display mode set
1198+
mode: "inline" | "fullscreen" | "pip" | "split" // Actual display mode set
11901199
}
11911200
}
11921201
```

src/app-bridge.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,77 @@ describe("App <-> AppBridge integration", () => {
624624
});
625625
});
626626

627+
describe("display mode negotiation", () => {
628+
it("split crosses the handshake and round-trips through requestDisplayMode", async () => {
629+
const [newAppTransport, newBridgeTransport] =
630+
InMemoryTransport.createLinkedPair();
631+
const splitApp = new App(
632+
testAppInfo,
633+
{ availableDisplayModes: ["inline", "split"] },
634+
{ autoResize: false },
635+
);
636+
const splitBridge = new AppBridge(
637+
createMockClient() as Client,
638+
testHostInfo,
639+
testHostCapabilities,
640+
{
641+
hostContext: {
642+
availableDisplayModes: ["inline", "fullscreen", "split"],
643+
},
644+
},
645+
);
646+
splitBridge.onrequestdisplaymode = async ({ mode }) => ({ mode });
647+
648+
await splitBridge.connect(newBridgeTransport);
649+
await splitApp.connect(newAppTransport);
650+
651+
expect(splitBridge.getAppCapabilities()?.availableDisplayModes).toEqual([
652+
"inline",
653+
"split",
654+
]);
655+
expect(splitApp.getHostContext()?.availableDisplayModes).toEqual([
656+
"inline",
657+
"fullscreen",
658+
"split",
659+
]);
660+
661+
const result = await splitApp.requestDisplayMode({ mode: "split" });
662+
expect(result.mode).toBe("split");
663+
664+
await newAppTransport.close();
665+
await newBridgeTransport.close();
666+
});
667+
668+
it("default handler returns the current mode from host context", async () => {
669+
const [newAppTransport, newBridgeTransport] =
670+
InMemoryTransport.createLinkedPair();
671+
const bridgeWithContext = new AppBridge(
672+
createMockClient() as Client,
673+
testHostInfo,
674+
testHostCapabilities,
675+
{ hostContext: { displayMode: "fullscreen" } },
676+
);
677+
678+
await bridgeWithContext.connect(newBridgeTransport);
679+
await app.connect(newAppTransport);
680+
const result = await app.requestDisplayMode({ mode: "split" });
681+
682+
expect(result.mode).toBe("fullscreen");
683+
684+
await newAppTransport.close();
685+
await newBridgeTransport.close();
686+
});
687+
688+
it("default handler falls back to inline when host context has no display mode", async () => {
689+
await bridge.connect(bridgeTransport);
690+
await app.connect(appTransport);
691+
692+
const result = await app.requestDisplayMode({ mode: "split" });
693+
694+
expect(result.mode).toBe("inline");
695+
});
696+
});
697+
627698
describe("deprecated method aliases", () => {
628699
beforeEach(async () => {
629700
await bridge.connect(bridgeTransport);

0 commit comments

Comments
 (0)