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
42 changes: 42 additions & 0 deletions examples/simple-host/src/AppRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export interface AppRendererProps {
onopenlink?: AppBridge["onopenlink"];
onmessage?: AppBridge["onmessage"];
onloggingmessage?: AppBridge["onloggingmessage"];
onrequestdisplaymode?: AppBridge["onrequestdisplaymode"];

/** Callback invoked when an error occurs during setup or message handling */
onerror?: (error: Error) => void;
Expand Down Expand Up @@ -107,26 +108,31 @@ export const AppRenderer = (props: AppRendererProps) => {
onmessage,
onopenlink,
onloggingmessage,
onrequestdisplaymode,
onerror,
} = props;

// State
const [appBridge, setAppBridge] = useState<AppBridge | null>(null);
const [iframeReady, setIframeReady] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [currentDisplayMode, setCurrentDisplayMode] =
useState<string>("inline");
const containerRef = useRef<HTMLDivElement | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);

// Use refs for callbacks to avoid effect re-runs when they change
const onmessageRef = useRef(onmessage);
const onopenlinkRef = useRef(onopenlink);
const onloggingmessageRef = useRef(onloggingmessage);
const onrequestdisplaymodeRef = useRef(onrequestdisplaymode);
const onerrorRef = useRef(onerror);

useEffect(() => {
onmessageRef.current = onmessage;
onopenlinkRef.current = onopenlink;
onloggingmessageRef.current = onloggingmessage;
onrequestdisplaymodeRef.current = onrequestdisplaymode;
onerrorRef.current = onerror;
});

Expand Down Expand Up @@ -193,6 +199,42 @@ export const AppRenderer = (props: AppRendererProps) => {
return onloggingmessageRef.current(params);
};

appBridge.onrequestdisplaymode = async (params, extra) => {
console.log(
"[Host] Display mode change requested:",
params.displayMode,
);

// If there's a custom handler, use it
if (onrequestdisplaymodeRef.current) {
return onrequestdisplaymodeRef.current(params, extra);
}

// Default implementation: accept inline mode, deny others
const requestedMode = params.displayMode;
const isInline = requestedMode === "inline";
const isFullscreen = requestedMode === "fullscreen";

if (
isInline ||
isFullscreen ||
requestedMode === currentDisplayMode
) {
setCurrentDisplayMode(requestedMode);
return {
success: true,
currentDisplayMode: requestedMode,
};
}

// Deny non-inline modes in this simple example
console.log("[Host] Display mode change denied:", requestedMode);
return {
success: false,
currentDisplayMode: currentDisplayMode,
};
};

appBridge.onsizechange = async ({ width, height }) => {
if (iframeRef.current) {
if (width !== undefined) {
Expand Down
59 changes: 59 additions & 0 deletions examples/simple-server/src/ui-react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const APP_INFO: Implementation = {
export function McpClientApp() {
const [toolResults, setToolResults] = useState<CallToolResult[]>([]);
const [messages, setMessages] = useState<string[]>([]);
const [currentDisplayMode, setCurrentDisplayMode] = useState<string>("");

const { app, isConnected, error } = useApp({
appInfo: APP_INFO,
Expand All @@ -29,6 +30,11 @@ export function McpClientApp() {
app.ontoolresult = async (params) => {
setToolResults((prev) => [...prev, params]);
};
app.onhostcontextchanged = (params) => {
if (params.displayMode) {
setCurrentDisplayMode(params.displayMode);
}
};
},
});

Expand Down Expand Up @@ -94,6 +100,23 @@ export function McpClientApp() {
]);
}, [app]);

const handleRequestDisplayMode = useCallback(
async (mode: "inline" | "fullscreen" | "pip" | "carousel") => {
if (!app) return;
try {
const result = await app.requestDisplayMode({ displayMode: mode });
setMessages((prev) => [
...prev,
`Display mode request (${mode}): ${result.success ? "success" : "denied"} - Current: ${result.currentDisplayMode}`,
]);
setCurrentDisplayMode(result.currentDisplayMode);
} catch (e) {
setMessages((prev) => [...prev, `Display mode error: ${e}`]);
}
},
[app],
);

if (error) {
return (
<div style={{ color: "red" }}>Error connecting: {error.message}</div>
Expand All @@ -108,6 +131,20 @@ export function McpClientApp() {
<div style={{ padding: "20px", fontFamily: "system-ui, sans-serif" }}>
<h1>MCP UI Client (React)</h1>

{currentDisplayMode && (
<div
style={{
marginBottom: "20px",
padding: "10px",
backgroundColor: "#e3f2fd",
border: "1px solid #2196f3",
borderRadius: "4px",
}}
>
<strong>Current Display Mode:</strong> {currentDisplayMode}
</div>
)}

<div
style={{
display: "flex",
Expand All @@ -123,6 +160,28 @@ export function McpClientApp() {
<button onClick={handlePromptWeather}>Prompt Weather in Tokyo</button>

<button onClick={handleOpenLink}>Open Link to Google</button>

<div
style={{
display: "flex",
gap: "5px",
flexWrap: "wrap",
marginTop: "10px",
}}
>
<button onClick={() => handleRequestDisplayMode("inline")}>
Request Inline
</button>
<button onClick={() => handleRequestDisplayMode("fullscreen")}>
Request Fullscreen
</button>
<button onClick={() => handleRequestDisplayMode("pip")}>
Request PiP
</button>
<button onClick={() => handleRequestDisplayMode("carousel")}>
Request Carousel
</button>
</div>
</div>

{toolResults.length > 0 && (
Expand Down
42 changes: 42 additions & 0 deletions specification/draft/apps.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,48 @@ MCP Apps introduces additional JSON-RPC methods for UI-specific functionality:

Host SHOULD open the URL in the user's default browser or a new tab.

`ui/request-display-mode` - Request display mode change

```typescript
// Request
{
jsonrpc: "2.0",
id: 2,
method: "ui/request-display-mode",
params: {
displayMode: "inline" | "fullscreen" | "pip" | "carousel"
}
}

// Success Response
{
jsonrpc: "2.0",
id: 2,
result: {
success: boolean, // Whether the request was honored
currentDisplayMode: string // The actual display mode after the request
}
}

// Error Response (if denied or failed)
{
jsonrpc: "2.0",
id: 2,
error: {
code: -32000, // Implementation-defined error
message: "Display mode change denied" | "Unsupported display mode"
}
}
```

Guest UI can request a display mode change from the Host. The Host maintains full control and MAY deny the request based on:
- User preferences or policies
- Platform constraints (e.g., mobile platforms may not support fullscreen)
- Current application state
- Security considerations

The Host SHOULD return `success: true` and update `currentDisplayMode` if the request is honored. If denied, the Host SHOULD return `success: false` with the current (unchanged) display mode. The Host MAY send a `ui/host-context-change` notification after honoring the request to update all context fields.

`ui/message` - Send message content to the host's chat interface

```typescript
Expand Down
46 changes: 46 additions & 0 deletions src/app-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ import {
McpUiOpenLinkRequest,
McpUiOpenLinkRequestSchema,
McpUiOpenLinkResult,
McpUiRequestDisplayModeRequest,
McpUiRequestDisplayModeRequestSchema,
McpUiRequestDisplayModeResult,
McpUiResourceTeardownRequest,
McpUiResourceTeardownResultSchema,
McpUiSandboxProxyReadyNotification,
Expand Down Expand Up @@ -454,6 +457,48 @@ export class AppBridge extends Protocol<Request, Notification, Result> {
},
);
}

/**
* Register a handler for display mode requests from the Guest UI.
*
* The Guest UI sends `ui/request-display-mode` requests when it wants to change
* its display mode (e.g., fullscreen, picture-in-picture). The handler should
* evaluate the request based on the host's capabilities and user preferences,
* then return the result indicating success or denial.
*
* @param callback - Handler that receives display mode params and returns a result
* - params.displayMode - Requested display mode ("inline", "fullscreen", "pip", "carousel")
* - extra - Request metadata (abort signal, session info)
* - Returns: Promise<McpUiRequestDisplayModeResult> with success flag and current mode
*
* @example
* ```typescript
* bridge.onrequestdisplaymode = async ({ displayMode }, extra) => {
* if (displayMode === "fullscreen" && !hostSupportsFullscreen()) {
* return { success: false, currentDisplayMode: "inline" };
* }
*
* await setAppDisplayMode(displayMode);
* return { success: true, currentDisplayMode: displayMode };
* };
* ```
*
* @see {@link McpUiRequestDisplayModeRequest} for the request type
* @see {@link McpUiRequestDisplayModeResult} for the result type
*/
set onrequestdisplaymode(
callback: (
params: McpUiRequestDisplayModeRequest["params"],
extra: RequestHandlerExtra,
) => Promise<McpUiRequestDisplayModeResult>,
) {
this.setRequestHandler(
McpUiRequestDisplayModeRequestSchema,
async (request, extra) => {
return callback(request.params, extra);
},
);
}

/**
* Register a handler for logging messages from the Guest UI.
Expand Down Expand Up @@ -482,6 +527,7 @@ export class AppBridge extends Protocol<Request, Notification, Result> {
* };
* ```
*/

set onloggingmessage(
callback: (params: LoggingMessageNotification["params"]) => void,
) {
Expand Down
43 changes: 43 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
McpUiMessageResultSchema,
McpUiOpenLinkRequest,
McpUiOpenLinkResultSchema,
McpUiRequestDisplayModeRequest,
McpUiRequestDisplayModeResultSchema,
McpUiSizeChangeNotification,
McpUiToolInputNotification,
McpUiToolInputNotificationSchema,
Expand Down Expand Up @@ -691,6 +693,46 @@ export class App extends Protocol<Request, Notification, Result> {
);
}

/**
* Request the host to change the app's display mode (e.g., fullscreen).
*
* Apps can request different display modes to optimize their UI for various
* contexts. The host may accept or reject the request based on user preferences
* or platform capabilities.
*
* @param params - Desired display mode
* @param options - Request options (timeout, etc.)
* @returns Result indicating success or error
*
* @throws {Error} If the host denies the request (e.g., unsupported mode)
* @throws {Error} If the request times out or the connection is lost
*
* @example Request fullscreen mode
* ```typescript
* try {
* await app.requestDisplayMode({ mode: "fullscreen" });
* } catch (error) {
* console.error("Failed to change display mode:", error);
* // Optionally adjust UI for current mode
* }
* ```
*
* @see {@link McpUiRequestDisplayModeRequest} for request structure
*/
requestDisplayMode(
params: McpUiRequestDisplayModeRequest["params"],
options?: RequestOptions,
) {
return this.request(
<McpUiRequestDisplayModeRequest>{
method: "ui/request-display-mode",
params,
},
McpUiRequestDisplayModeResultSchema,
options,
);
}

/**
* Notify the host of UI size changes.
*
Expand All @@ -711,6 +753,7 @@ export class App extends Protocol<Request, Notification, Result> {
*
* @see {@link McpUiSizeChangeNotification} for notification structure
*/

sendSizeChange(params: McpUiSizeChangeNotification["params"]) {
return this.notification(<McpUiSizeChangeNotification>{
method: "ui/notifications/size-change",
Expand Down
Loading