Skip to content

Commit 73ab853

Browse files
authored
Merge pull request #297 from modelcontextprotocol/feat/shadertoy-streaming-visibility
feat(shadertoy): add streaming partial input, visibility optimization, host styling
2 parents f71155a + f1a00f9 commit 73ab853

4 files changed

Lines changed: 187 additions & 3 deletions

File tree

examples/shadertoy-server/mcp-app.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
<body>
1010
<main class="main">
1111
<canvas id="canvas"></canvas>
12+
<pre id="code-preview"></pre>
1213
<button id="fullscreen-btn" class="fullscreen-btn" title="Toggle fullscreen">
1314
<svg class="expand-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1415
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>

examples/shadertoy-server/src/mcp-app.css

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ html, body {
1111
width: 100%;
1212
height: 100%;
1313
min-height: 400px;
14+
border-radius: var(--border-radius-lg);
15+
overflow: hidden;
16+
}
17+
18+
.main.fullscreen {
19+
border-radius: 0;
1420
}
1521

1622
#canvas {
@@ -19,6 +25,34 @@ html, body {
1925
display: block;
2026
}
2127

28+
#canvas.hidden {
29+
display: none;
30+
}
31+
32+
#code-preview {
33+
display: none;
34+
width: 100%;
35+
height: 100%;
36+
margin: 0;
37+
padding: 16px;
38+
box-sizing: border-box;
39+
overflow: auto;
40+
background: linear-gradient(
41+
135deg,
42+
var(--color-background-secondary) 0%,
43+
var(--color-background-tertiary) 100%
44+
);
45+
color: var(--color-text-ghost);
46+
font-family: var(--font-mono);
47+
font-size: var(--font-text-xs-size);
48+
line-height: var(--font-text-xs-line-height);
49+
white-space: pre-wrap;
50+
}
51+
52+
#code-preview.visible {
53+
display: block;
54+
}
55+
2256
/* Fullscreen button */
2357
.fullscreen-btn {
2458
position: absolute;

examples/shadertoy-server/src/mcp-app.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
/**
22
* ShaderToy renderer MCP App using ShaderToyLite.js
33
*/
4-
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
4+
import {
5+
App,
6+
type McpUiHostContext,
7+
applyHostStyleVariables,
8+
applyDocumentTheme,
9+
} from "@modelcontextprotocol/ext-apps";
510
import "./global.css";
611
import "./mcp-app.css";
712
import ShaderToyLite, {
@@ -34,6 +39,7 @@ const log = {
3439
// Get element references
3540
const mainEl = document.querySelector(".main") as HTMLElement;
3641
const canvas = document.getElementById("canvas") as HTMLCanvasElement;
42+
const codePreview = document.getElementById("code-preview") as HTMLPreElement;
3743
const fullscreenBtn = document.getElementById(
3844
"fullscreen-btn",
3945
) as HTMLButtonElement;
@@ -49,8 +55,12 @@ function resizeCanvas() {
4955
resizeCanvas();
5056
window.addEventListener("resize", resizeCanvas);
5157

52-
// Handle host context changes (display mode)
58+
// Handle host context changes (display mode, styling)
5359
function handleHostContextChanged(ctx: McpUiHostContext) {
60+
// Apply host styling
61+
if (ctx.theme) applyDocumentTheme(ctx.theme);
62+
if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
63+
5464
// Note: We ignore safeAreaInsets to maximize shader display area
5565

5666
// Show fullscreen button if available (only update if field is present)
@@ -112,9 +122,22 @@ app.onteardown = async () => {
112122
return {};
113123
};
114124

125+
app.ontoolinputpartial = (params) => {
126+
// Show code preview, hide canvas
127+
codePreview.classList.add("visible");
128+
canvas.classList.add("hidden");
129+
const code = params.arguments?.fragmentShader;
130+
codePreview.textContent = typeof code === "string" ? code : "";
131+
codePreview.scrollTop = codePreview.scrollHeight;
132+
};
133+
115134
app.ontoolinput = (params) => {
116135
log.info("Received shader input");
117136

137+
// Hide code preview, show canvas
138+
codePreview.classList.remove("visible");
139+
canvas.classList.remove("hidden");
140+
118141
if (!isShaderInput(params.arguments)) {
119142
log.error("Invalid tool input");
120143
return;
@@ -162,6 +185,18 @@ app.onerror = log.error;
162185

163186
app.onhostcontextchanged = handleHostContextChanged;
164187

188+
// Pause/resume shader based on visibility
189+
const observer = new IntersectionObserver((entries) => {
190+
entries.forEach((entry) => {
191+
if (entry.isIntersecting) {
192+
shaderToy?.play();
193+
} else {
194+
shaderToy?.pause();
195+
}
196+
});
197+
});
198+
observer.observe(mainEl);
199+
165200
// Connect to host
166201
app.connect().then(() => {
167202
log.info("Connected to host");

plugins/mcp-apps/skills/create-mcp-app/SKILL.md

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ Read JSDoc documentation directly from source:
8686

8787
| Example | Pattern Demonstrated |
8888
|---------|---------------------|
89+
| `examples/shadertoy-server/` | **Streaming partial input** + visibility-based pause/play (best practice for large inputs) |
8990
| `examples/wiki-explorer-server/` | `callServerTool` for interactive data fetching |
9091
| `examples/system-monitor-server/` | Polling pattern with interval management |
9192
| `examples/video-resource-server/` | Binary/blob resources |
@@ -173,9 +174,31 @@ app.onhostcontextchanged = (ctx) => {
173174
import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";
174175

175176
const { app } = useApp({ appInfo, capabilities, onAppCreated });
176-
useHostStyles(app); // Handles theme, styles, fonts automatically
177+
useHostStyles(app); // Injects CSS variables to document, making var(--*) available
177178
```
178179

180+
**Using variables in CSS** - After applying, use `var()`:
181+
```css
182+
.container {
183+
background: var(--color-background-secondary);
184+
color: var(--color-text-primary);
185+
font-family: var(--font-sans);
186+
border-radius: var(--border-radius-md);
187+
}
188+
.code {
189+
font-family: var(--font-mono);
190+
font-size: var(--font-text-sm-size);
191+
line-height: var(--font-text-sm-line-height);
192+
color: var(--color-text-secondary);
193+
}
194+
.heading {
195+
font-size: var(--font-heading-lg-size);
196+
font-weight: var(--font-weight-semibold);
197+
}
198+
```
199+
200+
Key variable groups: `--color-background-*`, `--color-text-*`, `--color-border-*`, `--font-sans`, `--font-mono`, `--font-text-*-size`, `--font-heading-*-size`, `--border-radius-*`. See `src/spec.types.ts` for full list.
201+
179202
### Safe Area Handling
180203

181204
Always respect `safeAreaInsets`:
@@ -189,6 +212,96 @@ app.onhostcontextchanged = (ctx) => {
189212
};
190213
```
191214

215+
### Streaming Partial Input
216+
217+
For large tool inputs, use `ontoolinputpartial` to show progress during LLM generation. The partial JSON is healed (always valid), enabling progressive UI updates.
218+
219+
**Spec:** [ui/notifications/tool-input-partial](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx#streaming-tool-input)
220+
221+
```typescript
222+
app.ontoolinputpartial = (params) => {
223+
const args = params.arguments; // Healed partial JSON - always valid, fields appear as generated
224+
// Use args directly for progressive rendering
225+
};
226+
227+
app.ontoolinput = (params) => {
228+
// Final complete input - switch from preview to full render
229+
};
230+
```
231+
232+
**Use cases:**
233+
| Pattern | Example |
234+
|---------|---------|
235+
| Code preview | Show streaming code in `<pre>`, render on complete (`examples/shadertoy-server/`) |
236+
| Progressive form | Fill form fields as they stream in |
237+
| Live chart | Add data points to chart as array grows |
238+
| Partial render | Render incomplete structured data (tables, lists, trees) |
239+
240+
**Simple pattern (code preview):**
241+
```typescript
242+
app.ontoolinputpartial = (params) => {
243+
codePreview.textContent = params.arguments?.code ?? "";
244+
codePreview.style.display = "block";
245+
canvas.style.display = "none";
246+
};
247+
app.ontoolinput = (params) => {
248+
codePreview.style.display = "none";
249+
canvas.style.display = "block";
250+
render(params.arguments);
251+
};
252+
```
253+
254+
### Visibility-Based Resource Management
255+
256+
Pause expensive operations (animations, WebGL, polling) when widget scrolls out of viewport:
257+
258+
```typescript
259+
const observer = new IntersectionObserver((entries) => {
260+
entries.forEach((entry) => {
261+
if (entry.isIntersecting) {
262+
animation.play(); // or: startPolling(), shaderToy.play()
263+
} else {
264+
animation.pause(); // or: stopPolling(), shaderToy.pause()
265+
}
266+
});
267+
});
268+
observer.observe(document.querySelector(".main"));
269+
```
270+
271+
### Fullscreen Mode
272+
273+
Request fullscreen via `app.requestDisplayMode()`. Check availability in host context:
274+
275+
```typescript
276+
let currentMode: "inline" | "fullscreen" = "inline";
277+
278+
app.onhostcontextchanged = (ctx) => {
279+
// Check if fullscreen available
280+
if (ctx.availableDisplayModes?.includes("fullscreen")) {
281+
fullscreenBtn.style.display = "block";
282+
}
283+
// Track current mode
284+
if (ctx.displayMode) {
285+
currentMode = ctx.displayMode;
286+
container.classList.toggle("fullscreen", currentMode === "fullscreen");
287+
}
288+
};
289+
290+
async function toggleFullscreen() {
291+
const newMode = currentMode === "fullscreen" ? "inline" : "fullscreen";
292+
const result = await app.requestDisplayMode({ mode: newMode });
293+
currentMode = result.mode;
294+
}
295+
```
296+
297+
**CSS pattern** - Remove border radius in fullscreen:
298+
```css
299+
.main { border-radius: var(--border-radius-lg); overflow: hidden; }
300+
.main.fullscreen { border-radius: 0; }
301+
```
302+
303+
See `examples/shadertoy-server/` for complete implementation.
304+
192305
## Common Mistakes to Avoid
193306

194307
1. **Handlers after connect()** - Register ALL handlers BEFORE calling `app.connect()`
@@ -198,6 +311,7 @@ app.onhostcontextchanged = (ctx) => {
198311
5. **Ignoring safe area insets** - Always handle `ctx.safeAreaInsets`
199312
6. **No text fallback** - Always provide `content` array for non-UI hosts
200313
7. **Hardcoded styles** - Use host CSS variables for theme integration
314+
8. **No streaming for large inputs** - Use `ontoolinputpartial` to show progress during generation
201315

202316
## Testing
203317

0 commit comments

Comments
 (0)