From a2ac33a61e8373531456840d89b974ddece8b395 Mon Sep 17 00:00:00 2001 From: Gabe Date: Thu, 27 Nov 2025 12:00:15 +0100 Subject: [PATCH 1/2] feat: add request display mode functionality to AppRenderer and McpClientApp --- examples/simple-host/src/AppRenderer.tsx | 42 +++++++++++++++++ examples/simple-server/src/ui-react.tsx | 59 ++++++++++++++++++++++++ specification/draft/apps.mdx | 42 +++++++++++++++++ src/app-bridge.ts | 16 +++++++ src/app.ts | 16 +++++++ src/types.ts | 18 ++++++++ 6 files changed, 193 insertions(+) diff --git a/examples/simple-host/src/AppRenderer.tsx b/examples/simple-host/src/AppRenderer.tsx index 146ec2d..5be9c21 100644 --- a/examples/simple-host/src/AppRenderer.tsx +++ b/examples/simple-host/src/AppRenderer.tsx @@ -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; @@ -107,6 +108,7 @@ export const AppRenderer = (props: AppRendererProps) => { onmessage, onopenlink, onloggingmessage, + onrequestdisplaymode, onerror, } = props; @@ -114,6 +116,8 @@ export const AppRenderer = (props: AppRendererProps) => { const [appBridge, setAppBridge] = useState(null); const [iframeReady, setIframeReady] = useState(false); const [error, setError] = useState(null); + const [currentDisplayMode, setCurrentDisplayMode] = + useState("inline"); const containerRef = useRef(null); const iframeRef = useRef(null); @@ -121,12 +125,14 @@ export const AppRenderer = (props: AppRendererProps) => { 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; }); @@ -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) { diff --git a/examples/simple-server/src/ui-react.tsx b/examples/simple-server/src/ui-react.tsx index a8239e9..2201d84 100644 --- a/examples/simple-server/src/ui-react.tsx +++ b/examples/simple-server/src/ui-react.tsx @@ -21,6 +21,7 @@ const APP_INFO: Implementation = { export function McpClientApp() { const [toolResults, setToolResults] = useState([]); const [messages, setMessages] = useState([]); + const [currentDisplayMode, setCurrentDisplayMode] = useState(""); const { app, isConnected, error } = useApp({ appInfo: APP_INFO, @@ -29,6 +30,11 @@ export function McpClientApp() { app.ontoolresult = async (params) => { setToolResults((prev) => [...prev, params]); }; + app.onhostcontextchanged = (params) => { + if (params.displayMode) { + setCurrentDisplayMode(params.displayMode); + } + }; }, }); @@ -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 (
Error connecting: {error.message}
@@ -108,6 +131,20 @@ export function McpClientApp() {

MCP UI Client (React)

+ {currentDisplayMode && ( +
+ Current Display Mode: {currentDisplayMode} +
+ )} +
Prompt Weather in Tokyo + +
+ + + + +
{toolResults.length > 0 && ( diff --git a/specification/draft/apps.mdx b/specification/draft/apps.mdx index 8175c6f..27ce964 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -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 diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 90756a7..7681b42 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -50,6 +50,9 @@ import { McpUiOpenLinkRequest, McpUiOpenLinkRequestSchema, McpUiOpenLinkResult, + McpUiRequestDisplayModeRequest, + McpUiRequestDisplayModeRequestSchema, + McpUiRequestDisplayModeResult, McpUiResourceTeardownRequest, McpUiResourceTeardownResultSchema, McpUiSandboxProxyReadyNotification, @@ -145,6 +148,19 @@ export class AppBridge extends Protocol { }, ); } + set onrequestdisplaymode( + callback: ( + params: McpUiRequestDisplayModeRequest["params"], + extra: RequestHandlerExtra, + ) => Promise, + ) { + this.setRequestHandler( + McpUiRequestDisplayModeRequestSchema, + async (request, extra) => { + return callback(request.params, extra); + }, + ); + } set onloggingmessage( callback: (params: LoggingMessageNotification["params"]) => void, ) { diff --git a/src/app.ts b/src/app.ts index a57bc59..c6c5fcb 100644 --- a/src/app.ts +++ b/src/app.ts @@ -31,6 +31,8 @@ import { McpUiMessageResultSchema, McpUiOpenLinkRequest, McpUiOpenLinkResultSchema, + McpUiRequestDisplayModeRequest, + McpUiRequestDisplayModeResultSchema, McpUiSizeChangeNotification, McpUiToolInputNotification, McpUiToolInputNotificationSchema, @@ -195,6 +197,20 @@ export class App extends Protocol { ); } + requestDisplayMode( + params: McpUiRequestDisplayModeRequest["params"], + options?: RequestOptions, + ) { + return this.request( + { + method: "ui/request-display-mode", + params, + }, + McpUiRequestDisplayModeResultSchema, + options, + ); + } + sendSizeChange(params: McpUiSizeChangeNotification["params"]) { return this.notification({ method: "ui/notifications/size-change", diff --git a/src/types.ts b/src/types.ts index d58bfc5..bb4d75c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,6 +24,24 @@ export const McpUiOpenLinkResultSchema = z.object({ }); export type McpUiOpenLinkResult = z.infer; +export const McpUiRequestDisplayModeRequestSchema = RequestSchema.extend({ + method: z.literal("ui/request-display-mode"), + params: z.object({ + displayMode: z.enum(["inline", "fullscreen", "pip", "carousel"]), + }), +}); +export type McpUiRequestDisplayModeRequest = z.infer< + typeof McpUiRequestDisplayModeRequestSchema +>; + +export const McpUiRequestDisplayModeResultSchema = z.object({ + success: z.boolean(), + currentDisplayMode: z.string(), +}); +export type McpUiRequestDisplayModeResult = z.infer< + typeof McpUiRequestDisplayModeResultSchema +>; + export const McpUiMessageRequestSchema = RequestSchema.extend({ method: z.literal("ui/message"), params: z.object({ From a7e2ce20ed64f26be143aff9094d8e1f1d659692 Mon Sep 17 00:00:00 2001 From: Gabe Date: Thu, 27 Nov 2025 23:40:02 +0100 Subject: [PATCH 2/2] Add display mode request handling for Guest UI --- src/app-bridge.ts | 28 ++++++++++++++++++++++++++++ src/app.ts | 26 ++++++++++++++++++++++++++ src/types.ts | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/src/app-bridge.ts b/src/app-bridge.ts index f84d219..66ac8e1 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -458,6 +458,34 @@ export class AppBridge extends Protocol { ); } + /** + * 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 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"], diff --git a/src/app.ts b/src/app.ts index 36b47cd..a0bf897 100644 --- a/src/app.ts +++ b/src/app.ts @@ -693,6 +693,32 @@ export class App extends Protocol { ); } + /** + * 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, diff --git a/src/types.ts b/src/types.ts index 3d5e17d..db3e4a4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -105,20 +105,54 @@ export const McpUiOpenLinkResultSchema: z.ZodType = isError: z.boolean().optional(), }); +/** @internal - Compile-time verification that schema matches interface */ +type _VerifyOpenLinkResult = VerifySchemaMatches< + typeof McpUiOpenLinkResultSchema, + McpUiOpenLinkResult +>; + +/** + * Request to change the display mode of the Guest UI (Guest UI → Host). + * + * Sent from the Guest UI to the Host when requesting a change in how the UI is + * displayed, such as switching to fullscreen or picture-in-picture mode. + * The host may deny the request based on user preferences or capabilities. + * + * @see {@link app.App.requestDisplayMode} for the method that sends this request + */ export const McpUiRequestDisplayModeRequestSchema = RequestSchema.extend({ method: z.literal("ui/request-display-mode"), params: z.object({ displayMode: z.enum(["inline", "fullscreen", "pip", "carousel"]), }), }); + +/** Request to change the display mode of the Guest UI. */ export type McpUiRequestDisplayModeRequest = z.infer< typeof McpUiRequestDisplayModeRequestSchema >; +/** @internal - Compile-time verification that schema matches interface */ +type _VerifyRequestDisplayModeRequest = VerifySchemaMatches< + typeof McpUiRequestDisplayModeRequestSchema, + McpUiRequestDisplayModeRequest +>; + +/** + * Runtime validation schema for {@link McpUiRequestDisplayModeResult}. + * @internal + */ export const McpUiRequestDisplayModeResultSchema = z.object({ success: z.boolean(), currentDisplayMode: z.string(), }); + +/** Result from a {@link McpUiRequestDisplayModeRequest}. + * + * The host returns this result after attempting to change the display mode. + * + * @see {@link McpUiRequestDisplayModeRequest} + */ export type McpUiRequestDisplayModeResult = z.infer< typeof McpUiRequestDisplayModeResultSchema >;