# Research: How MCP Servers Handle Rich Interactive UIs **Date:** 2026-02-03 **Source:** `github.com/modelcontextprotocol/ext-apps` (official repo) + MCP Apps blog/docs --- ## Executive Summary **There is ONE canonical pattern for interactivity in MCP Apps: `callServerTool`.** Every production example that has interactive UI uses `app.callServerTool()` from the client side to communicate with the server. There are NO alternative server-communication patterns. However, there are 3 complementary APIs for communicating with the HOST (not server): `updateModelContext`, `sendMessage`, and `openLink`. The key insight: **Interactivity in MCP Apps is entirely client-side JavaScript.** The server sends data once via tool results, and the UI is a fully interactive web app running in a sandboxed iframe. Buttons, sliders, drag-drop, forms — all work natively because it's just HTML/JS in an iframe. The only time you need `callServerTool` is when you need FRESH DATA from the server. --- ## The 5 Proven Interactivity Patterns ### Pattern 1: Client-Side State Only (Most Common) **Used by:** budget-allocator, scenario-modeler, customer-segmentation, cohort-heatmap The server sends ALL data upfront via `structuredContent` in the tool result. The UI is fully interactive using only local JavaScript state — no server calls needed after initial load. ``` Server → ontoolresult(structuredContent) → UI renders with local state User interacts → local JS handles everything (sliders, charts, forms) ``` **Example: Budget Allocator** — Server sends categories, history, benchmarks. Sliders, charts, percentile badges all update locally. Zero `callServerTool` calls during interaction. **Example: Scenario Modeler** — Server sends templates + default inputs. User adjusts sliders → React recalculates projections locally using `useMemo`. No server round-trips. **Key takeaway:** If you can send all needed data upfront, do it. This is the simplest and most responsive pattern. ### Pattern 2: callServerTool for On-Demand Data **Used by:** wiki-explorer, system-monitor, pdf-server The UI calls `app.callServerTool()` when it needs data the server didn't send initially. This is the ONLY way to get fresh data from the server. ``` User clicks "Expand" → app.callServerTool({ name: "get-first-degree-links", arguments: { url } }) Server returns structuredContent → UI updates graph ``` **Example: Wiki Explorer** — Initial tool result gives first page's links. When user clicks "Expand" on a node, it calls `callServerTool` to fetch that page's links. The graph visualization (force-directed d3) is entirely client-side. **Example: System Monitor** — Two tools: - `get-system-info` (model-visible): Returns static config once - `poll-system-stats` (app-only, `visibility: ["app"]`): Returns live metrics The UI polls `poll-system-stats` via `setInterval` + `callServerTool` every 2 seconds. ### Pattern 3: App-Only Tools with `visibility: ["app"]` **Used by:** system-monitor, pdf-server Tools marked `visibility: ["app"]` are hidden from the LLM — only the UI can call them. This is critical for polling, pagination, and UI-driven actions that shouldn't clutter model context. ```ts // Server: register app-only tool registerAppTool(server, "poll-system-stats", { _meta: { ui: { visibility: ["app"] } }, // Hidden from model // ... }); // Client: call it on interval setInterval(async () => { const result = await app.callServerTool({ name: "poll-system-stats", arguments: {} }); updateUI(result.structuredContent); }, 2000); ``` ### Pattern 4: updateModelContext (Inform the Model) **Used by:** map-server, transcript-server `app.updateModelContext()` pushes context TO the model without triggering a response. It tells the model what the user is currently seeing/doing. **Example: Map Server** — When user pans the map, debounced handler sends location + screenshot: ```ts app.updateModelContext({ content: [ { type: "text", text: `Map centered on [${lat}, ${lon}], ${widthKm}km wide` }, { type: "image", data: screenshotBase64, mimeType: "image/png" } ] }); ``` **Example: Transcript Server** — Updates model context with unsent transcript text using YAML frontmatter: ```ts app.updateModelContext({ content: [{ type: "text", text: `---\nstatus: listening\nunsent-entries: 3\n---\n\n${text}` }] }); ``` ### Pattern 5: sendMessage (Trigger Model Response) **Used by:** transcript-server `app.sendMessage()` sends a message AS the user, triggering a model response. Used when the UI wants the model to act on accumulated data. ```ts await app.sendMessage({ role: "user", content: [{ type: "text", text: transcriptText }] }); ``` --- ## How Specific Examples Handle Interactivity | Example | Interactive Elements | Pattern Used | callServerTool? | |---------|---------------------|-------------|----------------| | **Budget Allocator** | Sliders, dropdowns, charts, sparklines | Client-side state only | ❌ No | | **Scenario Modeler** | Sliders, template selector, React charts | Client-side state only | ❌ No | | **Cohort Heatmap** | Hover tooltips, data viz | Client-side state only | ❌ No | | **Customer Segmentation** | Scatter chart, tooltips | Client-side state only | ❌ No | | **Wiki Explorer** | Click nodes, expand graph, zoom, reset | callServerTool on user action | ✅ Yes | | **System Monitor** | Start/stop polling, live charts | callServerTool polling | ✅ Yes (every 2s) | | **Map Server** | Pan, zoom, fullscreen 3D globe | updateModelContext on move | ❌ No (data from tool input) | | **Transcript** | Start/stop recording, send transcript | updateModelContext + sendMessage | ❌ No | | **PDF Server** | Page navigation, zoom | callServerTool for chunks | ✅ Yes (chunked loading) | | **ShaderToy** | WebGL shader rendering | ontoolinputpartial for streaming | ❌ No | --- ## What Does NOT Exist (Common Misconceptions) 1. **No WebSocket/SSE from server to UI** — Communication is request/response via `callServerTool` only 2. **No server-push to UI** — The server cannot push data to the UI after initial tool result. The UI must poll. 3. **No shared state between tool calls** — Each HTTP request creates a new server instance (stateless) 4. **No DOM access from server** — The server never touches the UI. It only returns data. 5. **No "re-render" from server** — Once the UI is in the iframe, it's autonomous. The server doesn't control it. --- ## The Minimal Working Interactive MCP App ``` 1. Server registers tool with `_meta.ui.resourceUri` 2. Server registers resource that returns bundled HTML 3. HTML includes `