diff --git a/specification/draft/apps.mdx b/specification/draft/apps.mdx index 6e33f81..e5ff305 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -461,13 +461,18 @@ interface HostContext { displayMode?: "inline" | "fullscreen" | "pip"; /** Display modes the host supports */ availableDisplayModes?: string[]; - /** Current and maximum dimensions available to the UI */ - viewport?: { - width: number; - height: number; - maxHeight?: number; - maxWidth?: number; - }; + /** + * Viewport dimensions available to the UI. + * + * The viewport has two independent dimension pairs: + * - Height: Either `height` (fixed) or `maxHeight` (flexible), never both + * - Width: Either `width` (fixed) or `maxWidth` (flexible), never both + * + * Fixed dimensions (height/width): The host controls the size. Set height: 100% (recommended) or use the pixel value directly. + * Flexible dimensions (maxHeight/maxWidth or undefined): The app controls the size, up to the max if specified. + */ + viewport?: ({ height: number } | { maxHeight?: number }) & + ({ width: number } | { maxWidth?: number }); /** User's language/region preference (BCP 47, e.g., "en-US") */ locale?: string; /** User's timezone (IANA, e.g., "America/New_York") */ @@ -516,12 +521,76 @@ Example: } }, "displayMode": "inline", - "viewport": { "width": 400, "height": 300 } + "viewport": { "width": 400, "maxHeight": 600 } } } } ``` +### Viewport and Sizing + +The `viewport` field in `HostContext` communicates sizing constraints between host and app. Each dimension (height and width) operates independently and can be either **fixed** or **flexible**. + +#### Viewport Modes + +| Mode | Viewport Field | Meaning | +|------|---------------|---------| +| Fixed | `height` or `width` | Host controls the size. App should fill the available space. | +| Flexible | `maxHeight` or `maxWidth` | App controls the size, up to the specified maximum. | +| Unbounded | Field omitted | App controls the size with no limit. | + +These modes can be combined independently. For example, a host might specify a fixed width but flexible height, allowing the app to grow vertically based on content. + +#### App Behavior + +Apps should check the viewport configuration and apply appropriate CSS: + +```typescript +// In the app's initialization +const viewport = hostContext.viewport; + +if (viewport) { + // Handle height + if ("height" in viewport) { + // Fixed height: fill the container + document.body.style.height = "100%"; + } else if (viewport.maxHeight) { + // Flexible with max: let content determine size, up to max + document.body.style.maxHeight = `${viewport.maxHeight}px`; + } + // If neither, height is unbounded + + // Handle width + if ("width" in viewport) { + // Fixed width: fill the container + document.body.style.width = "100%"; + } else if (viewport.maxWidth) { + // Flexible with max: let content determine size, up to max + document.body.style.maxWidth = `${viewport.maxWidth}px`; + } + // If neither, width is unbounded +} +``` + +#### Host Behavior + +When using flexible dimensions (no fixed `height` or `width`), hosts MUST listen for `ui/notifications/size-changed` notifications from the app and update the iframe dimensions accordingly: + +```typescript +// Host listens for size changes from the app +bridge.onsizechange = ({ width, height }) => { + // Update iframe to match app's content size + if (width != null) { + iframe.style.width = `${width}px`; + } + if (height != null) { + iframe.style.height = `${height}px`; + } +}; +``` + +Apps using the SDK automatically send size-changed notifications via ResizeObserver when `autoResize` is enabled (the default). The notifications are debounced and only sent when dimensions actually change. + ### Theming Hosts can optionally pass CSS custom properties via `HostContext.styles.variables` for visual cohesion with the host environment. diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index 969030f..3424d69 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -113,7 +113,7 @@ describe("App <-> AppBridge integration", () => { const testHostContext = { theme: "dark" as const, locale: "en-US", - viewport: { width: 800, height: 600 }, + viewport: { width: 800, maxHeight: 600 }, }; const newBridge = new AppBridge( createMockClient() as Client, @@ -337,7 +337,7 @@ describe("App <-> AppBridge integration", () => { const initialContext = { theme: "light" as const, locale: "en-US", - viewport: { width: 800, height: 600 }, + viewport: { width: 800, maxHeight: 600 }, }; const newBridge = new AppBridge( createMockClient() as Client, @@ -356,7 +356,7 @@ describe("App <-> AppBridge integration", () => { // Send another partial update: only viewport changes newBridge.sendHostContextChange({ - viewport: { width: 1024, height: 768 }, + viewport: { width: 1024, maxHeight: 768 }, }); await flush(); @@ -367,7 +367,7 @@ describe("App <-> AppBridge integration", () => { const context = newApp.getHostContext(); expect(context?.theme).toBe("dark"); expect(context?.locale).toBe("en-US"); - expect(context?.viewport).toEqual({ width: 1024, height: 768 }); + expect(context?.viewport).toEqual({ width: 1024, maxHeight: 768 }); await newAppTransport.close(); await newBridgeTransport.close(); diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 2c63eaa..111be34 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -1008,7 +1008,7 @@ export class AppBridge extends Protocol< * ```typescript * bridge.setHostContext({ * theme: "dark", - * viewport: { width: 800, height: 600 } + * viewport: { width: 800, maxHeight: 600 } * }); * ``` * diff --git a/src/generated/schema.json b/src/generated/schema.json index e835986..f1a458c 100644 --- a/src/generated/schema.json +++ b/src/generated/schema.json @@ -629,28 +629,55 @@ } }, "viewport": { - "description": "Current and maximum dimensions available to the UI.", - "type": "object", - "properties": { - "width": { - "description": "Current viewport width in pixels.", - "type": "number" + "description": "Viewport dimensions available to the UI.\n\nThe viewport has two independent dimension pairs:\n- Height: Either `height` (fixed) or `maxHeight` (flexible), never both\n- Width: Either `width` (fixed) or `maxWidth` (flexible), never both\n\nFixed dimensions (height/width): The host controls the size. Set height: 100% (recommended) or use the pixel value directly.\nFlexible dimensions (maxHeight/maxWidth or undefined): The app controls the size, up to the max if specified. If undefined, there is no limit.", + "allOf": [ + { + "anyOf": [ + { + "type": "object", + "properties": { + "height": { + "type": "number" + } + }, + "required": ["height"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "maxHeight": { + "type": "number" + } + }, + "additionalProperties": false + } + ] }, - "height": { - "description": "Current viewport height in pixels.", - "type": "number" - }, - "maxHeight": { - "description": "Maximum available height in pixels (if constrained).", - "type": "number" - }, - "maxWidth": { - "description": "Maximum available width in pixels (if constrained).", - "type": "number" + { + "anyOf": [ + { + "type": "object", + "properties": { + "width": { + "type": "number" + } + }, + "required": ["width"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "maxWidth": { + "type": "number" + } + }, + "additionalProperties": false + } + ] } - }, - "required": ["width", "height"], - "additionalProperties": false + ] }, "locale": { "description": "User's language and region preference in BCP 47 format.", @@ -1255,28 +1282,55 @@ } }, "viewport": { - "description": "Current and maximum dimensions available to the UI.", - "type": "object", - "properties": { - "width": { - "description": "Current viewport width in pixels.", - "type": "number" + "description": "Viewport dimensions available to the UI.\n\nThe viewport has two independent dimension pairs:\n- Height: Either `height` (fixed) or `maxHeight` (flexible), never both\n- Width: Either `width` (fixed) or `maxWidth` (flexible), never both\n\nFixed dimensions (height/width): The host controls the size. Set height: 100% (recommended) or use the pixel value directly.\nFlexible dimensions (maxHeight/maxWidth or undefined): The app controls the size, up to the max if specified. If undefined, there is no limit.", + "allOf": [ + { + "anyOf": [ + { + "type": "object", + "properties": { + "height": { + "type": "number" + } + }, + "required": ["height"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "maxHeight": { + "type": "number" + } + }, + "additionalProperties": false + } + ] }, - "height": { - "description": "Current viewport height in pixels.", - "type": "number" - }, - "maxHeight": { - "description": "Maximum available height in pixels (if constrained).", - "type": "number" - }, - "maxWidth": { - "description": "Maximum available width in pixels (if constrained).", - "type": "number" + { + "anyOf": [ + { + "type": "object", + "properties": { + "width": { + "type": "number" + } + }, + "required": ["width"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "maxWidth": { + "type": "number" + } + }, + "additionalProperties": false + } + ] } - }, - "required": ["width", "height"], - "additionalProperties": false + ] }, "locale": { "description": "User's language and region preference in BCP 47 format.", @@ -2414,28 +2468,55 @@ } }, "viewport": { - "description": "Current and maximum dimensions available to the UI.", - "type": "object", - "properties": { - "width": { - "description": "Current viewport width in pixels.", - "type": "number" + "description": "Viewport dimensions available to the UI.\n\nThe viewport has two independent dimension pairs:\n- Height: Either `height` (fixed) or `maxHeight` (flexible), never both\n- Width: Either `width` (fixed) or `maxWidth` (flexible), never both\n\nFixed dimensions (height/width): The host controls the size. Set height: 100% (recommended) or use the pixel value directly.\nFlexible dimensions (maxHeight/maxWidth or undefined): The app controls the size, up to the max if specified. If undefined, there is no limit.", + "allOf": [ + { + "anyOf": [ + { + "type": "object", + "properties": { + "height": { + "type": "number" + } + }, + "required": ["height"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "maxHeight": { + "type": "number" + } + }, + "additionalProperties": false + } + ] }, - "height": { - "description": "Current viewport height in pixels.", - "type": "number" - }, - "maxHeight": { - "description": "Maximum available height in pixels (if constrained).", - "type": "number" - }, - "maxWidth": { - "description": "Maximum available width in pixels (if constrained).", - "type": "number" + { + "anyOf": [ + { + "type": "object", + "properties": { + "width": { + "type": "number" + } + }, + "required": ["width"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "maxWidth": { + "type": "number" + } + }, + "additionalProperties": false + } + ] } - }, - "required": ["width", "height"], - "additionalProperties": false + ] }, "locale": { "description": "User's language and region preference in BCP 47 format.", diff --git a/src/generated/schema.ts b/src/generated/schema.ts index 9e6120a..5558961 100644 --- a/src/generated/schema.ts +++ b/src/generated/schema.ts @@ -532,103 +532,114 @@ export const McpUiToolResultNotificationSchema = z.object({ /** * @description Rich context about the host environment provided to Guest UIs. */ -export const McpUiHostContextSchema = z - .object({ - /** @description Metadata of the tool call that instantiated this App. */ - toolInfo: z - .object({ - /** @description JSON-RPC id of the tools/call request. */ - id: RequestIdSchema.describe("JSON-RPC id of the tools/call request."), - /** @description Tool definition including name, inputSchema, etc. */ - tool: ToolSchema.describe( - "Tool definition including name, inputSchema, etc.", - ), - }) - .optional() - .describe("Metadata of the tool call that instantiated this App."), - /** @description Current color theme preference. */ - theme: McpUiThemeSchema.optional().describe( - "Current color theme preference.", +export const McpUiHostContextSchema = z.looseObject({ + /** @description Metadata of the tool call that instantiated this App. */ + toolInfo: z + .object({ + /** @description JSON-RPC id of the tools/call request. */ + id: RequestIdSchema.describe("JSON-RPC id of the tools/call request."), + /** @description Tool definition including name, inputSchema, etc. */ + tool: ToolSchema.describe( + "Tool definition including name, inputSchema, etc.", + ), + }) + .optional() + .describe("Metadata of the tool call that instantiated this App."), + /** @description Current color theme preference. */ + theme: McpUiThemeSchema.optional().describe( + "Current color theme preference.", + ), + /** @description Style configuration for theming the app. */ + styles: McpUiHostStylesSchema.optional().describe( + "Style configuration for theming the app.", + ), + /** @description How the UI is currently displayed. */ + displayMode: McpUiDisplayModeSchema.optional().describe( + "How the UI is currently displayed.", + ), + /** @description Display modes the host supports. */ + availableDisplayModes: z + .array(z.string()) + .optional() + .describe("Display modes the host supports."), + /** + * @description Viewport dimensions available to the UI. + * + * The viewport has two independent dimension pairs: + * - Height: Either `height` (fixed) or `maxHeight` (flexible), never both + * - Width: Either `width` (fixed) or `maxWidth` (flexible), never both + * + * Fixed dimensions (height/width): The host controls the size. Set height: 100% (recommended) or use the pixel value directly. + * Flexible dimensions (maxHeight/maxWidth or undefined): The app controls the size, up to the max if specified. If undefined, there is no limit. + */ + viewport: z + .union([ + z.object({ + height: z.number(), + }), + z.object({ + maxHeight: z.number().optional(), + }), + ]) + .and( + z.union([ + z.object({ + width: z.number(), + }), + z.object({ + maxWidth: z.number().optional(), + }), + ]), + ) + .optional() + .describe( + "Viewport dimensions available to the UI.\n\nThe viewport has two independent dimension pairs:\n- Height: Either `height` (fixed) or `maxHeight` (flexible), never both\n- Width: Either `width` (fixed) or `maxWidth` (flexible), never both\n\nFixed dimensions (height/width): The host controls the size. Set height: 100% (recommended) or use the pixel value directly.\nFlexible dimensions (maxHeight/maxWidth or undefined): The app controls the size, up to the max if specified. If undefined, there is no limit.", ), - /** @description Style configuration for theming the app. */ - styles: McpUiHostStylesSchema.optional().describe( - "Style configuration for theming the app.", - ), - /** @description How the UI is currently displayed. */ - displayMode: McpUiDisplayModeSchema.optional().describe( - "How the UI is currently displayed.", - ), - /** @description Display modes the host supports. */ - availableDisplayModes: z - .array(z.string()) - .optional() - .describe("Display modes the host supports."), - /** @description Current and maximum dimensions available to the UI. */ - viewport: z - .object({ - /** @description Current viewport width in pixels. */ - width: z.number().describe("Current viewport width in pixels."), - /** @description Current viewport height in pixels. */ - height: z.number().describe("Current viewport height in pixels."), - /** @description Maximum available height in pixels (if constrained). */ - maxHeight: z - .number() - .optional() - .describe("Maximum available height in pixels (if constrained)."), - /** @description Maximum available width in pixels (if constrained). */ - maxWidth: z - .number() - .optional() - .describe("Maximum available width in pixels (if constrained)."), - }) - .optional() - .describe("Current and maximum dimensions available to the UI."), - /** @description User's language and region preference in BCP 47 format. */ - locale: z - .string() - .optional() - .describe("User's language and region preference in BCP 47 format."), - /** @description User's timezone in IANA format. */ - timeZone: z.string().optional().describe("User's timezone in IANA format."), - /** @description Host application identifier. */ - userAgent: z.string().optional().describe("Host application identifier."), - /** @description Platform type for responsive design decisions. */ - platform: z - .union([z.literal("web"), z.literal("desktop"), z.literal("mobile")]) - .optional() - .describe("Platform type for responsive design decisions."), - /** @description Device input capabilities. */ - deviceCapabilities: z - .object({ - /** @description Whether the device supports touch input. */ - touch: z - .boolean() - .optional() - .describe("Whether the device supports touch input."), - /** @description Whether the device supports hover interactions. */ - hover: z - .boolean() - .optional() - .describe("Whether the device supports hover interactions."), - }) - .optional() - .describe("Device input capabilities."), - /** @description Mobile safe area boundaries in pixels. */ - safeAreaInsets: z - .object({ - /** @description Top safe area inset in pixels. */ - top: z.number().describe("Top safe area inset in pixels."), - /** @description Right safe area inset in pixels. */ - right: z.number().describe("Right safe area inset in pixels."), - /** @description Bottom safe area inset in pixels. */ - bottom: z.number().describe("Bottom safe area inset in pixels."), - /** @description Left safe area inset in pixels. */ - left: z.number().describe("Left safe area inset in pixels."), - }) - .optional() - .describe("Mobile safe area boundaries in pixels."), - }) - .passthrough(); + /** @description User's language and region preference in BCP 47 format. */ + locale: z + .string() + .optional() + .describe("User's language and region preference in BCP 47 format."), + /** @description User's timezone in IANA format. */ + timeZone: z.string().optional().describe("User's timezone in IANA format."), + /** @description Host application identifier. */ + userAgent: z.string().optional().describe("Host application identifier."), + /** @description Platform type for responsive design decisions. */ + platform: z + .union([z.literal("web"), z.literal("desktop"), z.literal("mobile")]) + .optional() + .describe("Platform type for responsive design decisions."), + /** @description Device input capabilities. */ + deviceCapabilities: z + .object({ + /** @description Whether the device supports touch input. */ + touch: z + .boolean() + .optional() + .describe("Whether the device supports touch input."), + /** @description Whether the device supports hover interactions. */ + hover: z + .boolean() + .optional() + .describe("Whether the device supports hover interactions."), + }) + .optional() + .describe("Device input capabilities."), + /** @description Mobile safe area boundaries in pixels. */ + safeAreaInsets: z + .object({ + /** @description Top safe area inset in pixels. */ + top: z.number().describe("Top safe area inset in pixels."), + /** @description Right safe area inset in pixels. */ + right: z.number().describe("Right safe area inset in pixels."), + /** @description Bottom safe area inset in pixels. */ + bottom: z.number().describe("Bottom safe area inset in pixels."), + /** @description Left safe area inset in pixels. */ + left: z.number().describe("Left safe area inset in pixels."), + }) + .optional() + .describe("Mobile safe area boundaries in pixels."), +}); /** * @description Notification that host context has changed (Host -> Guest UI). diff --git a/src/spec.types.ts b/src/spec.types.ts index f97a0a6..1a0c8c2 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -324,17 +324,18 @@ export interface McpUiHostContext { displayMode?: McpUiDisplayMode; /** @description Display modes the host supports. */ availableDisplayModes?: string[]; - /** @description Current and maximum dimensions available to the UI. */ - viewport?: { - /** @description Current viewport width in pixels. */ - width: number; - /** @description Current viewport height in pixels. */ - height: number; - /** @description Maximum available height in pixels (if constrained). */ - maxHeight?: number; - /** @description Maximum available width in pixels (if constrained). */ - maxWidth?: number; - }; + /** + * @description Viewport dimensions available to the UI. + * + * The viewport has two independent dimension pairs: + * - Height: Either `height` (fixed) or `maxHeight` (flexible), never both + * - Width: Either `width` (fixed) or `maxWidth` (flexible), never both + * + * Fixed dimensions (height/width): The host controls the size. Set height: 100% (recommended) or use the pixel value directly. + * Flexible dimensions (maxHeight/maxWidth or undefined): The app controls the size, up to the max if specified. If undefined, there is no limit. + */ + viewport?: ({ height: number } | { maxHeight?: number }) & + ({ width: number } | { maxWidth?: number }); /** @description User's language and region preference in BCP 47 format. */ locale?: string; /** @description User's timezone in IANA format. */