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 daab427..dc7cc94 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 8e682f3..66ac8e1 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, @@ -454,6 +457,48 @@ 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"], + extra: RequestHandlerExtra, + ) => Promise, + ) { + this.setRequestHandler( + McpUiRequestDisplayModeRequestSchema, + async (request, extra) => { + return callback(request.params, extra); + }, + ); + } /** * Register a handler for logging messages from the Guest UI. @@ -482,6 +527,7 @@ export class AppBridge extends Protocol { * }; * ``` */ + set onloggingmessage( callback: (params: LoggingMessageNotification["params"]) => void, ) { diff --git a/src/app.ts b/src/app.ts index 137c31f..a0bf897 100644 --- a/src/app.ts +++ b/src/app.ts @@ -31,6 +31,8 @@ import { McpUiMessageResultSchema, McpUiOpenLinkRequest, McpUiOpenLinkResultSchema, + McpUiRequestDisplayModeRequest, + McpUiRequestDisplayModeResultSchema, McpUiSizeChangeNotification, McpUiToolInputNotification, McpUiToolInputNotificationSchema, @@ -691,6 +693,46 @@ 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, + ) { + return this.request( + { + method: "ui/request-display-mode", + params, + }, + McpUiRequestDisplayModeResultSchema, + options, + ); + } + /** * Notify the host of UI size changes. * @@ -711,6 +753,7 @@ export class App extends Protocol { * * @see {@link McpUiSizeChangeNotification} for notification structure */ + sendSizeChange(params: McpUiSizeChangeNotification["params"]) { return this.notification({ method: "ui/notifications/size-change", diff --git a/src/types.ts b/src/types.ts index 0ce8540..db3e4a4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -105,6 +105,58 @@ 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 +>; + /** * Request to send a message to the host's chat interface. *