Skip to content

Commit c6c2428

Browse files
Improve patterns guide with concrete examples and safe area insets
- Rename "host styling" section to "host context" and add safe area insets handling - Replace abstract `renderLoadingUI`/`renderFinalUI` example with concrete code preview pattern for `ontoolinputpartial` - Rename callback parameter `params` → `ctx` for clarity across all `onhostcontextchanged` handlers - Add CSS snippet for fullscreen border-radius handling - Expand display mode example to show `availableDisplayModes` check - Fix minor typos and wording consistency Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 6c8379f commit c6c2428

8 files changed

Lines changed: 215 additions & 136 deletions

File tree

docs/patterns.md

Lines changed: 90 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ loadDataInChunks(resourceId, (loaded, total) => {
158158

159159
_See [`examples/pdf-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server) for a full implementation of this pattern._
160160

161-
## Giving errors back to model
161+
## Giving errors back to the model
162162

163163
**Server-side**: Tool handler validates inputs and returns `{ isError: true, content: [...] }`. The model receives this error through the normal tool call response.
164164

@@ -182,18 +182,19 @@ try {
182182
}
183183
```
184184

185-
## Matching host styling (CSS variables, theme, and fonts)
185+
## Adapting to host context (theme, styling, fonts, and safe areas)
186186

187-
Use the SDK's style helpers to apply host styling, then reference them in your CSS:
187+
The host provides context about its environment via {@link types!McpUiHostContext `McpUiHostContext`}. Use this to adapt your app's appearance and layout:
188188

189-
- **CSS variables** — Use `var(--color-background-primary)`, etc. in your CSS
190189
- **Theme** — Use `[data-theme="dark"]` selectors or `light-dark()` function for theme-aware styles
190+
- **CSS variables** — Use `var(--color-background-primary)`, etc. in your CSS (see {@link types!McpUiStyleVariableKey `McpUiStyleVariableKey`} for a full list)
191191
- **Fonts** — Use `var(--font-sans)` or `var(--font-mono)` with fallbacks (e.g., `font-family: var(--font-sans, system-ui, sans-serif)`)
192+
- **Safe area insets** — Apply padding to avoid device notches, rounded corners, or system UI overlays
192193

193194
**Vanilla JS:**
194195

195196
<!-- prettier-ignore -->
196-
```tsx source="./patterns.tsx#hostStylingVanillaJs"
197+
```tsx source="./patterns.tsx#hostContextVanillaJs"
197198
function applyHostContext(ctx: McpUiHostContext) {
198199
if (ctx.theme) {
199200
applyDocumentTheme(ctx.theme);
@@ -204,12 +205,18 @@ function applyHostContext(ctx: McpUiHostContext) {
204205
if (ctx.styles?.css?.fonts) {
205206
applyHostFonts(ctx.styles.css.fonts);
206207
}
208+
if (ctx.safeAreaInsets) {
209+
mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`;
210+
mainEl.style.paddingRight = `${ctx.safeAreaInsets.right}px`;
211+
mainEl.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`;
212+
mainEl.style.paddingLeft = `${ctx.safeAreaInsets.left}px`;
213+
}
207214
}
208215

209216
// Apply when host context changes
210217
app.onhostcontextchanged = applyHostContext;
211218

212-
// Apply initial styles after connecting
219+
// Apply initial context after connecting
213220
app.connect().then(() => {
214221
const ctx = app.getHostContext();
215222
if (ctx) {
@@ -221,61 +228,110 @@ app.connect().then(() => {
221228
**React:**
222229

223230
<!-- prettier-ignore -->
224-
```tsx source="./patterns.tsx#hostStylingReact"
231+
```tsx source="./patterns.tsx#hostContextReact"
225232
function MyApp() {
233+
const [hostContext, setHostContext] = useState<McpUiHostContext>();
234+
226235
const { app } = useApp({
227236
appInfo: { name: "MyApp", version: "1.0.0" },
228237
capabilities: {},
238+
onAppCreated: (app) => {
239+
app.onhostcontextchanged = (ctx) => {
240+
setHostContext((prev) => ({ ...prev, ...ctx }));
241+
};
242+
},
229243
});
230244

231-
// Apply all host styles (variables, theme, fonts)
232-
useHostStyles(app, app?.getHostContext());
245+
// Set initial host context after connection
246+
useEffect(() => {
247+
if (app) {
248+
setHostContext(app.getHostContext());
249+
}
250+
}, [app]);
251+
252+
// Apply styles when host context changes
253+
useEffect(() => {
254+
if (hostContext?.theme) {
255+
applyDocumentTheme(hostContext.theme);
256+
}
257+
if (hostContext?.styles?.variables) {
258+
applyHostStyleVariables(hostContext.styles.variables);
259+
}
260+
if (hostContext?.styles?.css?.fonts) {
261+
applyHostFonts(hostContext.styles.css.fonts);
262+
}
263+
}, [hostContext]);
233264

234265
return (
235266
<div
236267
style={{
237268
background: "var(--color-background-primary)",
238269
fontFamily: "var(--font-sans)",
270+
paddingTop: hostContext?.safeAreaInsets?.top,
271+
paddingRight: hostContext?.safeAreaInsets?.right,
272+
paddingBottom: hostContext?.safeAreaInsets?.bottom,
273+
paddingLeft: hostContext?.safeAreaInsets?.left,
239274
}}
240275
>
241-
<p>Styled with host CSS variables and fonts</p>
242-
<p className="theme-aware">Uses [data-theme] selectors</p>
276+
Styled with host CSS variables, fonts, and safe area insets
243277
</div>
244278
);
245279
}
246280
```
247281

248282
_See [`examples/basic-server-vanillajs/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs) and [`examples/basic-server-react/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react) for full implementations of this pattern._
249283

250-
## Entering / Exiting fullscreen
284+
## Entering / exiting fullscreen
251285

252286
Toggle fullscreen mode by calling {@link app!App.requestDisplayMode `requestDisplayMode`}:
253287

254288
<!-- prettier-ignore -->
255289
```ts source="../src/app.examples.ts#App_requestDisplayMode_toggle"
290+
const container = document.getElementById("main")!;
256291
const ctx = app.getHostContext();
257-
if (ctx?.availableDisplayModes?.includes("fullscreen")) {
258-
const target = ctx.displayMode === "fullscreen" ? "inline" : "fullscreen";
259-
const result = await app.requestDisplayMode({ mode: target });
260-
console.log("Now in:", result.mode);
292+
const newMode = ctx?.displayMode === "inline" ? "fullscreen" : "inline";
293+
if (ctx?.availableDisplayModes?.includes(newMode)) {
294+
const result = await app.requestDisplayMode({ mode: newMode });
295+
container.classList.toggle("fullscreen", result.mode === "fullscreen");
261296
}
262297
```
263298

264299
Listen for display mode changes via {@link app!App.onhostcontextchanged `onhostcontextchanged`} to update your UI:
265300

266301
<!-- prettier-ignore -->
267302
```ts source="../src/app.examples.ts#App_onhostcontextchanged_respondToDisplayMode"
268-
app.onhostcontextchanged = (params) => {
269-
if (params.displayMode) {
270-
const isFullscreen = params.displayMode === "fullscreen";
271-
document.body.classList.toggle("fullscreen", isFullscreen);
303+
app.onhostcontextchanged = (ctx) => {
304+
// Adjust to current display mode
305+
if (ctx.displayMode) {
306+
const container = document.getElementById("main")!;
307+
const isFullscreen = ctx.displayMode === "fullscreen";
308+
container.classList.toggle("fullscreen", isFullscreen);
309+
}
310+
311+
// Adjust display mode controls
312+
if (ctx.availableDisplayModes) {
313+
const fullscreenBtn = document.getElementById("fullscreen-btn")!;
314+
const canFullscreen = ctx.availableDisplayModes.includes("fullscreen");
315+
fullscreenBtn.style.display = canFullscreen ? "block" : "none";
272316
}
273317
};
274318
```
275319

320+
In fullscreen mode, remove the container's border radius so content extends to the viewport edges:
321+
322+
```css
323+
#main {
324+
border-radius: var(--border-radius-lg);
325+
326+
&.fullscreen {
327+
border-radius: 0;
328+
}
329+
}
330+
```
331+
276332
_See [`examples/shadertoy-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/shadertoy-server) for a full implementation of this pattern._
277333

278-
## Passing contextual information from the App to the Model
334+
## Passing contextual information from the App to the model
279335

280336
Use {@link app!App.updateModelContext `updateModelContext`} to keep the model informed about what the user is viewing or interacting with. Structure the content with YAML frontmatter for easy parsing:
281337

@@ -389,19 +445,19 @@ app.ontoolresult = (result) => {
389445

390446
_See [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server) for a full implementation of this pattern._
391447

392-
## Pausing computation-heavy views when out of view
448+
## Pausing computation-heavy views when offscreen
393449

394-
Views with animations, WebGL rendering, or polling can consume significant CPU/GPU even when scrolled out of view. Use [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) to pause expensive operations when the view isn't visible:
450+
Views with animations, WebGL rendering, or polling can consume significant CPU/GPU even when scrolled offscreen. Use [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) to pause expensive operations when the view isn't visible:
395451

396452
<!-- prettier-ignore -->
397453
```tsx source="./patterns.tsx#visibilityBasedPause"
398454
// Use IntersectionObserver to pause when view scrolls out of view
399455
const observer = new IntersectionObserver((entries) => {
400456
entries.forEach((entry) => {
401457
if (entry.isIntersecting) {
402-
animation.play();
458+
animation.play(); // or startPolling(), etc
403459
} else {
404-
animation.pause();
460+
animation.pause(); // or stopPolling(), etc
405461
}
406462
});
407463
});
@@ -419,31 +475,24 @@ _See [`examples/shadertoy-server/`](https://github.com/modelcontextprotocol/ext-
419475

420476
## Lowering perceived latency
421477

422-
Use {@link app!App.ontoolinputpartial `ontoolinputpartial`} to receive streaming tool arguments as they arrive, allowing you to show a loading preview before the complete input is available.
478+
Use {@link app!App.ontoolinputpartial `ontoolinputpartial`} to receive streaming tool arguments as they arrive. This lets you show a loading preview before the complete input is available, such as streaming code into a `<pre>` tag before executing it, partially rendering a table as data arrives, or incrementally populating a chart.
423479

424480
<!-- prettier-ignore -->
425481
```ts source="../src/app.examples.ts#App_ontoolinputpartial_progressiveRendering"
426-
let toolInputs: Record<string, unknown> | null = null;
427-
let toolInputsPartial: Record<string, unknown> | null = null;
482+
const codePreview = document.querySelector<HTMLPreElement>("#code-preview")!;
483+
const canvas = document.querySelector<HTMLCanvasElement>("#canvas")!;
428484

429485
app.ontoolinputpartial = (params) => {
430-
toolInputsPartial = params.arguments as Record<string, unknown>;
431-
render();
486+
codePreview.textContent = (params.arguments?.code as string) ?? "";
487+
codePreview.style.display = "block";
488+
canvas.style.display = "none";
432489
};
433490

434491
app.ontoolinput = (params) => {
435-
toolInputs = params.arguments as Record<string, unknown>;
436-
toolInputsPartial = null;
437-
render();
492+
codePreview.style.display = "none";
493+
canvas.style.display = "block";
494+
render(params.arguments?.code as string);
438495
};
439-
440-
function render() {
441-
if (toolInputs) {
442-
renderFinalUI(toolInputs);
443-
} else {
444-
renderLoadingUI(toolInputsPartial); // e.g., shimmer with partial preview
445-
}
446-
}
447496
```
448497

449498
> [!IMPORTANT]

docs/patterns.tsx

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import {
1616
import { randomUUID } from "node:crypto";
1717
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
1818
import type { McpUiHostContext } from "../src/types.js";
19-
import { useApp, useHostStyles } from "../src/react/index.js";
19+
import { useEffect, useState } from "react";
20+
import { useApp } from "../src/react/index.js";
2021
import { registerAppTool } from "../src/server/index.js";
2122
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2223
import { z } from "zod";
@@ -147,10 +148,10 @@ function chunkedDataClient(app: App, resourceId: string) {
147148
}
148149

149150
/**
150-
* Example: Unified host styling (theme, CSS variables, fonts)
151+
* Example: Adapting to host context (theme, CSS variables, fonts, safe areas)
151152
*/
152-
function hostStylingVanillaJs(app: App) {
153-
//#region hostStylingVanillaJs
153+
function hostContextVanillaJs(app: App, mainEl: HTMLElement) {
154+
//#region hostContextVanillaJs
154155
function applyHostContext(ctx: McpUiHostContext) {
155156
if (ctx.theme) {
156157
applyDocumentTheme(ctx.theme);
@@ -161,48 +162,81 @@ function hostStylingVanillaJs(app: App) {
161162
if (ctx.styles?.css?.fonts) {
162163
applyHostFonts(ctx.styles.css.fonts);
163164
}
165+
if (ctx.safeAreaInsets) {
166+
mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`;
167+
mainEl.style.paddingRight = `${ctx.safeAreaInsets.right}px`;
168+
mainEl.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`;
169+
mainEl.style.paddingLeft = `${ctx.safeAreaInsets.left}px`;
170+
}
164171
}
165172

166173
// Apply when host context changes
167174
app.onhostcontextchanged = applyHostContext;
168175

169-
// Apply initial styles after connecting
176+
// Apply initial context after connecting
170177
app.connect().then(() => {
171178
const ctx = app.getHostContext();
172179
if (ctx) {
173180
applyHostContext(ctx);
174181
}
175182
});
176-
//#endregion hostStylingVanillaJs
183+
//#endregion hostContextVanillaJs
177184
}
178185

179186
/**
180-
* Example: Host styling with React (CSS variables, theme, fonts)
187+
* Example: Adapting to host context with React (CSS variables, theme, fonts, safe areas)
181188
*/
182-
function hostStylingReact() {
183-
//#region hostStylingReact
189+
function hostContextReact() {
190+
//#region hostContextReact
184191
function MyApp() {
192+
const [hostContext, setHostContext] = useState<McpUiHostContext>();
193+
185194
const { app } = useApp({
186195
appInfo: { name: "MyApp", version: "1.0.0" },
187196
capabilities: {},
197+
onAppCreated: (app) => {
198+
app.onhostcontextchanged = (ctx) => {
199+
setHostContext((prev) => ({ ...prev, ...ctx }));
200+
};
201+
},
188202
});
189203

190-
// Apply all host styles (variables, theme, fonts)
191-
useHostStyles(app, app?.getHostContext());
204+
// Set initial host context after connection
205+
useEffect(() => {
206+
if (app) {
207+
setHostContext(app.getHostContext());
208+
}
209+
}, [app]);
210+
211+
// Apply styles when host context changes
212+
useEffect(() => {
213+
if (hostContext?.theme) {
214+
applyDocumentTheme(hostContext.theme);
215+
}
216+
if (hostContext?.styles?.variables) {
217+
applyHostStyleVariables(hostContext.styles.variables);
218+
}
219+
if (hostContext?.styles?.css?.fonts) {
220+
applyHostFonts(hostContext.styles.css.fonts);
221+
}
222+
}, [hostContext]);
192223

193224
return (
194225
<div
195226
style={{
196227
background: "var(--color-background-primary)",
197228
fontFamily: "var(--font-sans)",
229+
paddingTop: hostContext?.safeAreaInsets?.top,
230+
paddingRight: hostContext?.safeAreaInsets?.right,
231+
paddingBottom: hostContext?.safeAreaInsets?.bottom,
232+
paddingLeft: hostContext?.safeAreaInsets?.left,
198233
}}
199234
>
200-
<p>Styled with host CSS variables and fonts</p>
201-
<p className="theme-aware">Uses [data-theme] selectors</p>
235+
Styled with host CSS variables, fonts, and safe area insets
202236
</div>
203237
);
204238
}
205-
//#endregion hostStylingReact
239+
//#endregion hostContextReact
206240
}
207241

208242
/**
@@ -284,9 +318,9 @@ function visibilityBasedPause(
284318
const observer = new IntersectionObserver((entries) => {
285319
entries.forEach((entry) => {
286320
if (entry.isIntersecting) {
287-
animation.play();
321+
animation.play(); // or startPolling(), etc
288322
} else {
289-
animation.pause();
323+
animation.pause(); // or stopPolling(), etc
290324
}
291325
});
292326
});
@@ -304,8 +338,8 @@ function visibilityBasedPause(
304338
// Suppress unused variable warnings
305339
void chunkedDataServer;
306340
void chunkedDataClient;
307-
void hostStylingVanillaJs;
308-
void hostStylingReact;
341+
void hostContextVanillaJs;
342+
void hostContextReact;
309343
void persistViewStateServer;
310344
void persistViewState;
311345
void visibilityBasedPause;

0 commit comments

Comments
 (0)