Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions examples/basic-host/src/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
14 changes: 9 additions & 5 deletions examples/basic-host/src/implementation.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -262,15 +262,19 @@ function hookInitializedCallback(appBridge: AppBridge): Promise<void> {
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(
Expand All @@ -296,7 +300,7 @@ export function newAppBridge(
},
containerDimensions: options?.containerDimensions ?? { maxHeight: 6000 },
displayMode: options?.displayMode ?? "inline",
availableDisplayModes: ["inline", "fullscreen"],
availableDisplayModes: [...HOST_AVAILABLE_DISPLAY_MODES],
},
});

Expand Down Expand Up @@ -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,
Expand Down
48 changes: 48 additions & 0 deletions examples/basic-host/src/index.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
44 changes: 38 additions & 6 deletions examples/basic-host/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 (
<div
className={styles.splitResizeHandle}
title="Drag to resize"
onPointerDown={(e) => {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
}}
onPointerMove={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
setSplitViewWidth(e.clientX);
}
}}
/>
);
}

interface AppIFramePanelProps {
toolCallInfo: Required<ToolCallInfo>;
isDestroying?: boolean;
Expand All @@ -427,7 +455,7 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
const appBridgeRef = useRef<ReturnType<typeof newAppBridge> | null>(null);
const [modelContext, setModelContext] = useState<ModelContext | null>(null);
const [messages, setMessages] = useState<AppMessage[]>([]);
const [displayMode, setDisplayMode] = useState<"inline" | "fullscreen">("inline");
const [displayMode, setDisplayMode] = useState<HostDisplayMode>("inline");

useEffect(() => {
const iframe = iframeRef.current!;
Expand Down Expand Up @@ -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 (
<div className={panelClassName}>
<div className={panelClassName} data-display-mode={displayMode}>
{displayMode === "split" && <SplitResizeHandle />}
<iframe ref={iframeRef} />
{messages.length > 0 && (
<CollapsiblePanel
Expand Down
1 change: 1 addition & 0 deletions examples/debug-server/mcp-app.html
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ <h3>Display Mode</h3>
<button id="display-inline-btn" class="btn-small">Inline</button>
<button id="display-fullscreen-btn" class="btn-small">Fullscreen</button>
<button id="display-pip-btn" class="btn-small">PiP</button>
<button id="display-split-btn" class="btn-small">Split</button>
</div>
</div>

Expand Down
17 changes: 12 additions & 5 deletions examples/debug-server/src/mcp-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
*
* This app exercises every capability, callback, and result format combination.
*/
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import {
App,
type McpUiDisplayMode,
type McpUiHostContext,
} from "@modelcontextprotocol/ext-apps";
import "./global.css";
import "./mcp-app.css";

Expand Down Expand Up @@ -99,6 +103,7 @@ const updateContextImageBtn = document.getElementById(
const displayInlineBtn = document.getElementById("display-inline-btn")!;
const displayFullscreenBtn = document.getElementById("display-fullscreen-btn")!;
const displayPipBtn = document.getElementById("display-pip-btn")!;
const displaySplitBtn = document.getElementById("display-split-btn")!;

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

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

Expand Down Expand Up @@ -513,9 +521,7 @@ updateContextImageBtn.addEventListener("click", async () => {
// Display Mode Actions
// ============================================================================

async function requestDisplayMode(
mode: "inline" | "fullscreen" | "pip",
): Promise<void> {
async function requestDisplayMode(mode: McpUiDisplayMode): Promise<void> {
try {
const result = await app.requestDisplayMode({ mode });
logEvent("display-mode-result", { mode, result });
Expand All @@ -529,6 +535,7 @@ displayFullscreenBtn.addEventListener("click", () =>
requestDisplayMode("fullscreen"),
);
displayPipBtn.addEventListener("click", () => requestDisplayMode("pip"));
displaySplitBtn.addEventListener("click", () => requestDisplayMode("split"));

// ============================================================================
// Link Action
Expand Down
19 changes: 14 additions & 5 deletions specification/draft/apps.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,7 @@ interface McpUiAppCapabilities {
* Display modes the app supports. See Display Modes section for details.
* @example ["inline", "fullscreen"]
*/
availableDisplayModes?: Array<"inline" | "fullscreen" | "pip">;
availableDisplayModes?: Array<"inline" | "fullscreen" | "pip" | "split">;
}
```

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

```typescript
type McpUiDisplayMode = "inline" | "fullscreen" | "pip";
type McpUiDisplayMode = "inline" | "fullscreen" | "pip" | "split";
```

- **inline**: Default mode, embedded within the host's content flow
- **fullscreen**: View takes over the full screen/window
- **pip**: Picture-in-picture, floating overlay
- **split**: View is displayed in a persistent, non-overlapping region while the host's primary conversational interface remains visible and interactive

#### Split Mode

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.

- 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.
- 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.
- This mode does not define reusing Views across separate tool calls; each tool call still renders a new View instance.

#### Declaring Support

Expand Down Expand Up @@ -1177,7 +1186,7 @@ Host behavior:
id: 3,
method: "ui/request-display-mode",
params: {
mode: "inline" | "fullscreen" | "pip" // Requested display mode
mode: "inline" | "fullscreen" | "pip" | "split" // Requested display mode
}
}

Expand All @@ -1186,7 +1195,7 @@ Host behavior:
jsonrpc: "2.0",
id: 3,
result: {
mode: "inline" | "fullscreen" | "pip" // Actual display mode set
mode: "inline" | "fullscreen" | "pip" | "split" // Actual display mode set
}
}
```
Expand Down
71 changes: 71 additions & 0 deletions src/app-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,77 @@ describe("App <-> AppBridge integration", () => {
});
});

describe("display mode negotiation", () => {
it("split crosses the handshake and round-trips through requestDisplayMode", async () => {
const [newAppTransport, newBridgeTransport] =
InMemoryTransport.createLinkedPair();
const splitApp = new App(
testAppInfo,
{ availableDisplayModes: ["inline", "split"] },
{ autoResize: false },
);
const splitBridge = new AppBridge(
createMockClient() as Client,
testHostInfo,
testHostCapabilities,
{
hostContext: {
availableDisplayModes: ["inline", "fullscreen", "split"],
},
},
);
splitBridge.onrequestdisplaymode = async ({ mode }) => ({ mode });

await splitBridge.connect(newBridgeTransport);
await splitApp.connect(newAppTransport);

expect(splitBridge.getAppCapabilities()?.availableDisplayModes).toEqual([
"inline",
"split",
]);
expect(splitApp.getHostContext()?.availableDisplayModes).toEqual([
"inline",
"fullscreen",
"split",
]);

const result = await splitApp.requestDisplayMode({ mode: "split" });
expect(result.mode).toBe("split");

await newAppTransport.close();
await newBridgeTransport.close();
});

it("default handler returns the current mode from host context", async () => {
const [newAppTransport, newBridgeTransport] =
InMemoryTransport.createLinkedPair();
const bridgeWithContext = new AppBridge(
createMockClient() as Client,
testHostInfo,
testHostCapabilities,
{ hostContext: { displayMode: "fullscreen" } },
);

await bridgeWithContext.connect(newBridgeTransport);
await app.connect(newAppTransport);
const result = await app.requestDisplayMode({ mode: "split" });

expect(result.mode).toBe("fullscreen");

await newAppTransport.close();
await newBridgeTransport.close();
});

it("default handler falls back to inline when host context has no display mode", async () => {
await bridge.connect(bridgeTransport);
await app.connect(appTransport);

const result = await app.requestDisplayMode({ mode: "split" });

expect(result.mode).toBe("inline");
});
});

describe("deprecated method aliases", () => {
beforeEach(async () => {
await bridge.connect(bridgeTransport);
Expand Down
Loading