docs: standardize UI terminology to use "view" consistently (#325)

* docs: standardize UI terminology to use "view" consistently

Replaces "widget" and "Guest UI" terminology with "view" across
the codebase as decided in the community survey.

This change updates:
- Specification document
- SDK source code and JSDoc comments
- Example servers and documentation
- Plugin documentation and skills

Closes #71

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Liad Yosef 2026-01-23 04:36:52 +02:00 • committed by GitHub
commit 4fc71656a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 381 additions and 393 deletions

View file

@ -6,7 +6,7 @@ MCP Apps SDK (`@modelcontextprotocol/ext-apps`) enables MCP servers to display i
Key abstractions:
- **Guest** - UI running in an iframe, uses `App` class with `PostMessageTransport` to communicate with host
- **View** - UI running in an iframe, uses `App` class with `PostMessageTransport` to communicate with host
- **Host** - Chat client embedding the iframe, uses `AppBridge` class to proxy MCP requests
- **Server** - MCP server that registers tools/resources with UI metadata
@ -67,14 +67,14 @@ rm -fR package-lock.json node_modules && \
### Protocol Flow
```
Guest UI (App) <--PostMessageTransport--> Host (AppBridge) <--MCP Client--> MCP Server
View (App) <--PostMessageTransport--> Host (AppBridge) <--MCP Client--> MCP Server
```
1. Host creates iframe with Guest UI HTML
2. Guest UI creates `App` instance and calls `connect()` with `PostMessageTransport`
3. App sends `ui/initialize` request, receives host capabilities and context
1. Host creates iframe with view HTML
2. View creates `App` instance and calls `connect()` with `PostMessageTransport`
3. View sends `ui/initialize` request, receives host capabilities and context
4. Host sends `sendToolInput()` with tool arguments after initialization
5. Guest UI can call server tools via `app.callServerTool()` or send messages via `app.sendMessage()`
5. View can call server tools via `app.callServerTool()` or send messages via `app.sendMessage()`
6. Host sends `sendToolResult()` when tool execution completes
7. Host calls `teardownResource()` before unmounting iframe

View file

@ -72,7 +72,7 @@ function createServer() {
inputSchema: { userId: z.string() },
annotations: { readOnlyHint: true },
_meta: {
"openai/outputTemplate": "ui://widget/cart.html",
"openai/outputTemplate": "ui://view/cart.html",
"openai/toolInvocation/invoking": "Loading cart...",
"openai/toolInvocation/invoked": "Cart ready",
"openai/widgetAccessible": true,
@ -89,13 +89,13 @@ function createServer() {
// Register UI resource
server.registerResource(
"Cart Widget",
"ui://widget/cart.html",
"Cart View",
"ui://view/cart.html",
{ mimeType: "text/html+skybridge" },
async () => ({
contents: [
{
uri: "ui://widget/cart.html",
uri: "ui://view/cart.html",
mimeType: "text/html+skybridge",
text: getCartHtml(),
_meta: {
@ -137,7 +137,7 @@ function createServer() {
description: "Display the user's shopping cart",
inputSchema: { userId: z.string() },
annotations: { readOnlyHint: true },
_meta: { ui: { resourceUri: "ui://widget/cart.html" } },
_meta: { ui: { resourceUri: "ui://view/cart.html" } },
},
async (args) => {
const cart = await getCart(args.userId);
@ -151,13 +151,13 @@ function createServer() {
// Register UI resource
registerAppResource(
server,
"Cart Widget",
"ui://widget/cart.html",
"Cart View",
"ui://view/cart.html",
{ description: "Shopping cart UI" },
async () => ({
contents: [
{
uri: "ui://widget/cart.html",
uri: "ui://view/cart.html",
mimeType: RESOURCE_MIME_TYPE,
text: getCartHtml(),
_meta: {

View file

@ -82,11 +82,11 @@ When you need to send more data than fits in a message, use {@link app!App.updat
_See [`examples/transcript-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/transcript-server) for a full implementation of this pattern._
## Persisting widget state
## Persisting view state
To persist widget state across conversation reloads (e.g., current page in a PDF viewer, camera position in a map), use [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) with a stable identifier provided by the server.
To persist view state across conversation reloads (e.g., current page in a PDF viewer, camera position in a map), use [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) with a stable identifier provided by the server.
**Server-side**: Tool handler generates a unique `widgetUUID` and returns it in `CallToolResult._meta.widgetUUID`:
**Server-side**: Tool handler generates a unique `viewUUID` and returns it in `CallToolResult._meta.viewUUID`:
{@includeCode ./patterns.tsx#persistDataServer}
@ -96,9 +96,9 @@ To persist widget state across conversation reloads (e.g., current page in a PDF
_See [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server) for a full implementation of this pattern._
## Pausing computation-heavy widgets when out of view
## Pausing computation-heavy views when out of view
Widgets 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 widget isn't visible:
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:
{@includeCode ./patterns.tsx#visibilityBasedPause}

View file

@ -206,21 +206,17 @@ function hostStylingReact() {
}
/**
* Example: Persisting widget state (server-side)
* Example: Persisting view state (server-side)
*/
function persistWidgetStateServer(
url: string,
title: string,
pageCount: number,
) {
function persistViewStateServer(url: string, title: string, pageCount: number) {
function toolCallback(): CallToolResult {
//#region persistDataServer
// In your tool callback, include widgetUUID in the result metadata.
// In your tool callback, include viewUUID in the result metadata.
return {
content: [{ type: "text", text: `Displaying PDF viewer for "${title}"` }],
structuredContent: { url, title, pageCount, initialPage: 1 },
_meta: {
widgetUUID: randomUUID(),
viewUUID: randomUUID(),
},
};
//#endregion persistDataServer
@ -228,39 +224,39 @@ function persistWidgetStateServer(
}
/**
* Example: Persisting widget state (client-side)
* Example: Persisting view state (client-side)
*/
function persistWidgetState(app: App) {
function persistViewState(app: App) {
//#region persistData
// Store the widgetUUID received from the server
let widgetUUID: string | undefined;
// Store the viewUUID received from the server
let viewUUID: string | undefined;
// Helper to save state to localStorage
function saveState<T>(state: T): void {
if (!widgetUUID) return;
if (!viewUUID) return;
try {
localStorage.setItem(widgetUUID, JSON.stringify(state));
localStorage.setItem(viewUUID, JSON.stringify(state));
} catch (err) {
console.error("Failed to save widget state:", err);
console.error("Failed to save view state:", err);
}
}
// Helper to load state from localStorage
function loadState<T>(): T | null {
if (!widgetUUID) return null;
if (!viewUUID) return null;
try {
const saved = localStorage.getItem(widgetUUID);
const saved = localStorage.getItem(viewUUID);
return saved ? (JSON.parse(saved) as T) : null;
} catch (err) {
console.error("Failed to load widget state:", err);
console.error("Failed to load view state:", err);
return null;
}
}
// Receive widgetUUID from the tool result
// Receive viewUUID from the tool result
app.ontoolresult = (result) => {
widgetUUID = result._meta?.widgetUUID
? String(result._meta.widgetUUID)
viewUUID = result._meta?.viewUUID
? String(result._meta.viewUUID)
: undefined;
// Restore any previously saved state
@ -270,13 +266,13 @@ function persistWidgetState(app: App) {
}
};
// Call saveState() whenever your widget state changes
// Call saveState() whenever your view state changes
// e.g., saveState({ currentPage: 5 });
//#endregion persistData
}
/**
* Example: Pausing computation-heavy widgets when out of view
* Example: Pausing computation-heavy views when out of view
*/
function visibilityBasedPause(
app: App,
@ -284,7 +280,7 @@ function visibilityBasedPause(
animation: { play: () => void; pause: () => void },
) {
//#region visibilityBasedPause
// Use IntersectionObserver to pause when widget scrolls out of view
// Use IntersectionObserver to pause when view scrolls out of view
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
@ -296,7 +292,7 @@ function visibilityBasedPause(
});
observer.observe(container);
// Clean up when the host tears down the widget
// Clean up when the host tears down the view
app.onteardown = async () => {
observer.disconnect();
animation.pause();
@ -310,6 +306,6 @@ void chunkedDataServer;
void chunkedDataClient;
void hostStylingVanillaJs;
void hostStylingReact;
void persistWidgetStateServer;
void persistWidgetState;
void persistViewStateServer;
void persistViewState;
void visibilityBasedPause;

View file

@ -277,7 +277,7 @@ export function newAppBridge(
},
});
// Register all handlers before calling connect(). The Guest UI can start
// Register all handlers before calling connect(). The view can start
// sending requests immediately after the initialization handshake, so any
// handlers registered after connect() might miss early requests.

View file

@ -56,7 +56,7 @@ const PROXY_READY_NOTIFICATION: McpUiSandboxProxyReadyNotification["method"] =
// Message relay: This Sandbox (outer iframe) acts as a bidirectional bridge,
// forwarding messages between:
//
// Host (parent window) ↔ Sandbox (outer frame) ↔ Guest UI (inner iframe)
// Host (parent window) ↔ Sandbox (outer frame) ↔ View (inner iframe)
//
// Reason: the parent window and inner iframe have different origins and can't
// communicate directly, so the outer iframe forwards messages in both
@ -64,7 +64,7 @@ const PROXY_READY_NOTIFICATION: McpUiSandboxProxyReadyNotification["method"] =
//
// Special case: The "ui/notifications/sandbox-proxy-ready" message is
// intercepted here (not relayed) because the Sandbox uses it to configure and
// load the inner iframe with the Guest UI HTML content.
// load the inner iframe with the view HTML content.
//
// Security: CSP is enforced via HTTP headers on sandbox.html (set by serve.ts
// based on ?csp= query param). This is tamper-proof unlike meta tags.
@ -128,7 +128,7 @@ window.addEventListener("message", async (event) => {
}
});
// Notify the Host that the Sandbox is ready to receive Guest UI HTML.
// Notify the Host that the Sandbox is ready to receive view HTML.
// Use specific origin instead of "*" to ensure only the expected host receives this.
window.parent.postMessage({
jsonrpc: "2.0",

View file

@ -185,7 +185,7 @@ export function createServer(): McpServer {
},
],
_meta: {
widgetUUID: randomUUID(),
viewUUID: randomUUID(),
},
}),
);

View file

@ -71,7 +71,7 @@ let persistViewTimer: ReturnType<typeof setTimeout> | null = null;
// Track whether tool input has been received (to know if we should restore persisted state)
let hasReceivedToolInput = false;
let widgetUUID: string | undefined = undefined;
let viewUUID: string | undefined = undefined;
/**
* Persisted camera state for localStorage
@ -122,7 +122,7 @@ function schedulePersistViewState(cesiumViewer: any): void {
* Persist current view state to localStorage
*/
function persistViewState(cesiumViewer: any): void {
if (!widgetUUID) {
if (!viewUUID) {
log.info("No storage key available, skipping view persistence");
return;
}
@ -132,8 +132,8 @@ function persistViewState(cesiumViewer: any): void {
try {
const value = JSON.stringify(state);
localStorage.setItem(widgetUUID, value);
log.info("Persisted view state:", widgetUUID, value);
localStorage.setItem(viewUUID, value);
log.info("Persisted view state:", viewUUID, value);
} catch (e) {
log.warn("Failed to persist view state:", e);
}
@ -143,10 +143,10 @@ function persistViewState(cesiumViewer: any): void {
* Load persisted view state from localStorage
*/
function loadPersistedViewState(): PersistedCameraState | null {
if (!widgetUUID) return null;
if (!viewUUID) return null;
try {
const stored = localStorage.getItem(widgetUUID);
const stored = localStorage.getItem(viewUUID);
if (!stored) {
console.info("No persisted view state found");
return null;
@ -938,16 +938,14 @@ app.ontoolinput = async (params) => {
// },
// );
// Handle tool result - extract widgetUUID and restore persisted view if available
// Handle tool result - extract viewUUID and restore persisted view if available
app.ontoolresult = async (result) => {
widgetUUID = result._meta?.widgetUUID
? String(result._meta.widgetUUID)
: undefined;
log.info("Tool result received, widgetUUID:", widgetUUID);
viewUUID = result._meta?.viewUUID ? String(result._meta.viewUUID) : undefined;
log.info("Tool result received, viewUUID:", viewUUID);
// Now that we have widgetUUID, try to restore persisted view
// Now that we have viewUUID, try to restore persisted view
// This overrides the tool input position if a saved state exists
if (viewer && widgetUUID) {
if (viewer && viewUUID) {
const restored = restorePersistedView(viewer);
if (restored) {
log.info("Restored persisted view from tool result handler");

View file

@ -189,7 +189,7 @@ The viewer supports zoom, navigation, text selection, and fullscreen mode.`,
],
structuredContent: result,
_meta: {
widgetUUID: randomUUID(),
viewUUID: randomUUID(),
},
};
},

View file

@ -35,7 +35,7 @@ let totalPages = 0;
let scale = 1.0;
let pdfUrl = "";
let pdfTitle: string | undefined;
let widgetUUID: string | undefined;
let viewUUID: string | undefined;
let currentRenderTask: { cancel: () => void } | null = null;
// DOM Elements
@ -404,10 +404,10 @@ async function renderPage() {
}
function saveCurrentPage() {
log.info("saveCurrentPage: key=", widgetUUID, "page=", currentPage);
if (widgetUUID) {
log.info("saveCurrentPage: key=", viewUUID, "page=", currentPage);
if (viewUUID) {
try {
localStorage.setItem(widgetUUID, String(currentPage));
localStorage.setItem(viewUUID, String(currentPage));
log.info("saveCurrentPage: saved successfully");
} catch (err) {
log.error("saveCurrentPage: error", err);
@ -416,10 +416,10 @@ function saveCurrentPage() {
}
function loadSavedPage(): number | null {
log.info("loadSavedPage: key=", widgetUUID);
if (!widgetUUID) return null;
log.info("loadSavedPage: key=", viewUUID);
if (!viewUUID) return null;
try {
const saved = localStorage.getItem(widgetUUID);
const saved = localStorage.getItem(viewUUID);
log.info("loadSavedPage: saved value=", saved);
if (saved) {
const page = parseInt(saved, 10);
@ -706,9 +706,7 @@ app.ontoolresult = async (result) => {
pdfUrl = parsed.url;
pdfTitle = parsed.title;
totalPages = parsed.pageCount;
widgetUUID = result._meta?.widgetUUID
? String(result._meta.widgetUUID)
: undefined;
viewUUID = result._meta?.viewUUID ? String(result._meta.viewUUID) : undefined;
// Restore saved page or use initial page
const savedPage = loadSavedPage();

View file

@ -1,6 +1,6 @@
# QR Code MCP Server
A minimal Python MCP server that generates customizable QR codes with an interactive widget UI.
A minimal Python MCP server that generates customizable QR codes with an interactive view UI.
![Screenshot](https://modelcontextprotocol.github.io/ext-apps/screenshots/qr-server/screenshot.png)
@ -33,7 +33,7 @@ Then add to your MCP client configuration (stdio transport), replacing the path
- Generate QR codes from any text or URL
- Customizable colors, size, and error correction
- Interactive widget that displays in MCP-UI enabled clients
- Interactive view that displays in MCP-UI enabled clients
- Supports both HTTP (for web clients) and stdio (for MCP clients)
## Prerequisites
@ -151,19 +151,19 @@ Generate a QR code with optional customization.
```
qr-server/
├── server.py # MCP server (FastMCP + uvicorn, deps inline via PEP 723)
├── widget.html # Interactive UI widget
├── view.html # Interactive UI view
└── README.md
```
### Protocol
The widget uses MCP Apps SDK protocol:
The view uses MCP Apps SDK protocol:
1. Widget sends `ui/initialize` request
1. The view sends `ui/initialize` request
2. Host responds with capabilities
3. Widget sends `ui/notifications/initialized`
3. The view sends `ui/notifications/initialized`
4. Host sends `ui/notifications/tool-result` with QR image
5. Widget renders image and sends `ui/notifications/size-changed`
5. The view renders image and sends `ui/notifications/size-changed`
## Dependencies

View file

@ -22,14 +22,14 @@ from mcp.server.fastmcp import FastMCP
from mcp import types
from starlette.middleware.cors import CORSMiddleware
WIDGET_URI = "ui://qr-server/widget.html"
VIEW_URI = "ui://qr-server/view.html"
HOST = os.environ.get("HOST", "0.0.0.0") # 0.0.0.0 for Docker compatibility
PORT = int(os.environ.get("PORT", "3108"))
mcp = FastMCP("QR Code Server")
# Embedded widget HTML for self-contained usage (uv run <url> or unbundled)
EMBEDDED_WIDGET_HTML = """<!DOCTYPE html>
# Embedded View HTML for self-contained usage (uv run <url> or unbundled)
EMBEDDED_VIEW_HTML = """<!DOCTYPE html>
<html>
<head>
<meta name="color-scheme" content="light dark">
@ -60,7 +60,7 @@ EMBEDDED_WIDGET_HTML = """<!DOCTYPE html>
<script type="module">
import { App } from "https://unpkg.com/@modelcontextprotocol/ext-apps@0.4.0/app-with-deps";
const app = new App({ name: "QR Widget", version: "1.0.0" });
const app = new App({ name: "QR View", version: "1.0.0" });
app.ontoolresult = ({ content }) => {
const img = content?.find(c => c.type === 'image');
@ -100,8 +100,8 @@ EMBEDDED_WIDGET_HTML = """<!DOCTYPE html>
@mcp.tool(meta={
"ui":{"resourceUri": WIDGET_URI},
"ui/resourceUri": WIDGET_URI, # legacy support
"ui":{"resourceUri": VIEW_URI},
"ui/resourceUri": VIEW_URI, # legacy support
})
def generate_qr(
text: str = "https://modelcontextprotocol.io",
@ -147,13 +147,13 @@ def generate_qr(
# IMPORTANT: all the external domains used by app must be listed
# in the meta.ui.csp.resourceDomains - otherwise they will be blocked by CSP policy
@mcp.resource(
WIDGET_URI,
VIEW_URI,
mime_type="text/html;profile=mcp-app",
meta={"ui": {"csp": {"resourceDomains": ["https://unpkg.com"]}}},
)
def widget() -> str:
"""Widget HTML resource with CSP metadata for external dependencies."""
return EMBEDDED_WIDGET_HTML
def view() -> str:
"""View HTML resource with CSP metadata for external dependencies."""
return EMBEDDED_VIEW_HTML
if __name__ == "__main__":
if "--stdio" in sys.argv:

View file

@ -30,13 +30,13 @@ Add to your MCP client configuration (stdio transport):
This example showcases several MCP App capabilities:
- **Single-file executable**: Python server with embedded React UI - no build step required
- **Partial tool inputs** (`ontoolinputpartial`): Widget receives streaming text as it's being generated
- **Partial tool inputs** (`ontoolinputpartial`): The view receives streaming text as it's being generated
- **Queue-based streaming**: Demonstrates how to stream text out and audio in via a polling tool (adds text to an input queue, retrieves audio chunks from an output queue)
- **Model context updates**: Widget updates the LLM with playback progress ("Playing: ...snippet...")
- **Model context updates**: The view updates the LLM with playback progress ("Playing: ...snippet...")
- **Native theming**: Uses CSS variables for automatic dark/light mode adaptation
- **Fullscreen mode**: Toggle fullscreen via `requestDisplayMode()` API, press Escape to exit
- **Multi-widget speak lock**: Coordinates multiple TTS widgets via localStorage so only one plays at a time
- **Hidden tools** (`visibility: ["app"]`): Private tools only accessible to the widget, not the model
- **Multi-view speak lock**: Coordinates multiple TTS views via localStorage so only one plays at a time
- **Hidden tools** (`visibility: ["app"]`): Private tools only accessible to the view, not the model
- **External links** (`openLink`): Attribution popup uses `app.openLink()` to open external URLs
- **CSP metadata**: Resource declares required domains (`esm.sh`) for in-browser transpilation
@ -132,29 +132,29 @@ See the [kyutai/tts-voices](https://huggingface.co/kyutai/tts-voices) repository
The entire server is contained in a single `server.py` file:
1. **`say` tool**: Public tool that triggers the widget with text to speak
2. **Private tools** (`create_tts_queue`, `add_tts_text`, `poll_tts_audio`, etc.): Hidden from the model, only callable by the widget
3. **Embedded React widget**: Uses [Babel standalone](https://babeljs.io/docs/babel-standalone) for in-browser JSX transpilation - no build step needed
1. **`say` tool**: Public tool that triggers the view with text to speak
2. **Private tools** (`create_tts_queue`, `add_tts_text`, `poll_tts_audio`, etc.): Hidden from the model, only callable by the view
3. **Embedded React view**: Uses [Babel standalone](https://babeljs.io/docs/babel-standalone) for in-browser JSX transpilation - no build step needed
4. **TTS backend**: Manages per-request audio queues using Pocket TTS
The widget communicates with the server via MCP tool calls:
The view communicates with the server via MCP tool calls:
- Receives streaming text via `ontoolinputpartial` callback
- Incrementally sends new text to the server as it arrives (via `add_tts_text`)
- Polls for generated audio chunks while TTS runs in parallel
- Plays audio via Web Audio API with synchronized text highlighting
## Multi-Widget Speak Lock
## Multi-view Speak Lock
When multiple TTS widgets exist in the same browser (e.g., multiple chat messages each with their own say widget), they coordinate via localStorage to ensure only one plays at a time:
When multiple TTS views exist in the same browser (e.g., multiple chat messages each with their own say view), they coordinate via localStorage to ensure only one plays at a time:
1. **Unique Widget IDs**: Each widget receives a UUID via `toolResult._meta.widgetUUID`
2. **Announce on Play**: When starting, a widget writes `{uuid, timestamp}` to `localStorage["mcp-tts-playing"]`
3. **Poll for Conflicts**: Every 200ms, playing widgets check if another widget took the lock
4. **Yield Gracefully**: If another widget started playing, pause and yield
1. **Unique view IDs**: Each view receives a UUID via `toolResult._meta.viewUUID`
2. **Announce on Play**: When starting, a view writes `{uuid, timestamp}` to `localStorage["mcp-tts-playing"]`
3. **Poll for Conflicts**: Every 200ms, playing views check if another view took the lock
4. **Yield Gracefully**: If another view started playing, pause and yield
5. **Clean Up**: On pause/finish, clear the lock (only if owned)
This "last writer wins" protocol ensures a seamless experience: clicking play on any widget immediately pauses others, without requiring cross-iframe postMessage coordination.
This "last writer wins" protocol ensures a seamless experience: clicking play on any view immediately pauses others, without requiring cross-iframe postMessage coordination.
## TODO

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<title>Say Widget</title>
<title>Say View</title>
<link rel="stylesheet" href="/src/global.css">
</head>
<body>

View file

@ -12,17 +12,17 @@
Say Demo - MCP App for streaming text-to-speech.
This MCP server provides a "say" tool that speaks text using TTS.
The widget receives streaming partial input and starts speaking immediately.
The view receives streaming partial input and starts speaking immediately.
Architecture:
- The `say` tool itself is a no-op - it just triggers the widget
- The widget uses `ontoolinputpartial` to receive text as it streams
- Widget calls private tools to create TTS queue, add text, and poll audio
- Audio plays in the widget using Web Audio API
- The `say` tool itself is a no-op - it just triggers the view
- The view uses `ontoolinputpartial` to receive text as it streams
- view calls private tools to create TTS queue, add text, and poll audio
- Audio plays in the view using Web Audio API
- Model context updates show playback progress to the LLM
- Native theming adapts to dark/light mode automatically
- Fullscreen mode with Escape key to exit
- Multi-widget speak lock coordinates playback across instances
- Multi-View speak lock coordinates playback across instances
Usage:
# Start the MCP server
@ -56,7 +56,7 @@ from pocket_tts.default_parameters import DEFAULT_AUDIO_PROMPT
logger = logging.getLogger(__name__)
WIDGET_URI = "ui://say-demo/widget.html"
VIEW_URI = "ui://say-demo/view.html"
HOST = os.environ.get("HOST", "0.0.0.0")
PORT = int(os.environ.get("PORT", "3109"))
@ -167,8 +167,8 @@ def list_voices() -> list[types.TextContent]:
@mcp.tool(meta={
"ui":{"resourceUri": WIDGET_URI},
"ui/resourceUri": WIDGET_URI, # legacy support
"ui":{"resourceUri": VIEW_URI},
"ui/resourceUri": VIEW_URI, # legacy support
})
def say(
text: Annotated[str, Field(description="The English text to speak aloud")] = DEFAULT_TEXT,
@ -192,19 +192,19 @@ def say(
Note: English only. Non-English text may produce poor or garbled results.
"""
# Generate a unique ID for this widget instance (used for speak lock coordination)
widget_uuid = uuid.uuid4().hex[:12]
# Generate a unique ID for this view instance (used for speak lock coordination)
view_uuid = uuid.uuid4().hex[:12]
# This is a no-op - the widget handles everything via ontoolinputpartial
# This is a no-op - the view handles everything via ontoolinputpartial
# The tool exists to:
# 1. Trigger the widget to load
# 1. Trigger the view to load
# 2. Provide the resourceUri metadata
# 3. Show the final text in the tool result
# 4. Provide widget UUID for multi-player coordination
# 4. Provide view UUID for multi-player coordination
return [types.TextContent(
type="text",
text=f"Displayed a TTS widget with voice '{voice}'. Click to play/pause, use toolbar to restart or fullscreen.",
_meta={"widgetUUID": widget_uuid},
text=f"Displayed a TTS view with voice '{voice}'. Click to play/pause, use toolbar to restart or fullscreen.",
_meta={"viewUUID": view_uuid},
)]
@ -348,7 +348,7 @@ def poll_tts_audio(queue_id: str) -> list[types.TextContent]:
new_chunks = state.audio_chunks[state.chunks_delivered:]
state.chunks_delivered = len(state.audio_chunks)
# Consider queues with errors as "done" so widget stops polling
# Consider queues with errors as "done" so view stops polling
done = (state.status == "complete" or state.status == "error") and state.chunks_delivered >= len(state.audio_chunks)
response = {
@ -661,18 +661,18 @@ async def _process_tts_chunk(
# ------------------------------------------------------
# Widget Resource
# View Resource
# ------------------------------------------------------
# Embedded widget HTML for standalone execution via `uv run <url>`
# Embedded View HTML for standalone execution via `uv run <url>`
# Uses Babel standalone for in-browser JSX transpilation
# This is a copy of widget.html - keep them in sync!
EMBEDDED_WIDGET_HTML = """<!DOCTYPE html>
# This is a copy of view.html - keep them in sync!
EMBEDDED_VIEW_HTML = """<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Say Widget</title>
<title>Say View</title>
<script src="https://unpkg.com/@babel/standalone@7.26.10/babel.min.js"></script>
<script type="importmap">
{
@ -784,7 +784,7 @@ EMBEDDED_WIDGET_HTML = """<!DOCTYPE html>
import { createRoot } from 'react-dom/client';
import { useApp } from '@modelcontextprotocol/ext-apps/react';
function SayWidget() {
function SayView() {
const [hostContext, setHostContext] = useState(undefined);
const [displayText, setDisplayText] = useState("");
const [charPosition, setCharPosition] = useState(0);
@ -796,7 +796,7 @@ EMBEDDED_WIDGET_HTML = """<!DOCTYPE html>
const [showInfo, setShowInfo] = useState(false);
const voiceRef = useRef("cosette"); // Current voice, updated from tool input
const widgetUuidRef = useRef(null); // Widget UUID for speak lock coordination
const viewUuidRef = useRef(null); // View UUID for speak lock coordination
const speakLockIntervalRef = useRef(null); // Polling interval for speak lock
const queueIdRef = useRef(null);
const audioContextRef = useRef(null);
@ -816,22 +816,22 @@ EMBEDDED_WIDGET_HTML = """<!DOCTYPE html>
const initQueuePromiseRef = useRef(null);
const pendingModelContextUpdateRef = useRef(null);
// Speak lock: coordinates multiple TTS widgets so only one plays at a time
// Speak lock: coordinates multiple TTS views so only one plays at a time
const SPEAK_LOCK_KEY = "mcp-tts-playing";
const announcePlayback = useCallback(() => {
if (!widgetUuidRef.current) return;
if (!viewUuidRef.current) return;
localStorage.setItem(SPEAK_LOCK_KEY, JSON.stringify({
uuid: widgetUuidRef.current,
uuid: viewUuidRef.current,
timestamp: Date.now()
}));
}, []);
const clearSpeakLock = useCallback(() => {
if (!widgetUuidRef.current) return;
if (!viewUuidRef.current) return;
try {
const current = JSON.parse(localStorage.getItem(SPEAK_LOCK_KEY) || "null");
if (current?.uuid === widgetUuidRef.current) {
if (current?.uuid === viewUuidRef.current) {
localStorage.removeItem(SPEAK_LOCK_KEY);
}
} catch {}
@ -842,9 +842,9 @@ EMBEDDED_WIDGET_HTML = """<!DOCTYPE html>
speakLockIntervalRef.current = setInterval(() => {
try {
const current = JSON.parse(localStorage.getItem(SPEAK_LOCK_KEY) || "null");
if (current && current.uuid !== widgetUuidRef.current) {
if (current && current.uuid !== viewUuidRef.current) {
// Someone else started playing - yield
console.log('[TTS] Another widget started playing, pausing');
console.log('[TTS] Another view started playing, pausing');
onStolenCallback();
}
} catch {}
@ -1161,7 +1161,7 @@ EMBEDDED_WIDGET_HTML = """<!DOCTYPE html>
}, [displayMode]);
const { app, error } = useApp({
appInfo: { name: "Say Widget", version: "1.0.0" },
appInfo: { name: "Say View", version: "1.0.0" },
capabilities: {},
onHostContextChanged: (ctx) => {
if (ctx.availableDisplayModes?.includes("fullscreen")) {
@ -1219,11 +1219,11 @@ EMBEDDED_WIDGET_HTML = """<!DOCTYPE html>
app.ontoolresult = async (params) => {
console.log('[TTS] ontoolresult called, queueId:', queueIdRef.current);
fullTextRef.current = lastTextRef.current;
// Read widget UUID from tool result _meta for speak lock coordination
const resultUuid = params.content?.[0]?._meta?.widgetUUID;
// Read View UUID from tool result _meta for speak lock coordination
const resultUuid = params.content?.[0]?._meta?.viewUUID;
if (resultUuid) {
widgetUuidRef.current = resultUuid;
console.log('[TTS] Widget UUID:', resultUuid);
viewUuidRef.current = resultUuid;
console.log('[TTS] View UUID:', resultUuid);
}
if (queueIdRef.current) {
console.log('[TTS] Calling end_tts_queue for:', queueIdRef.current);
@ -1267,14 +1267,14 @@ EMBEDDED_WIDGET_HTML = """<!DOCTYPE html>
}
}, [app]);
// Speak lock: coordinate with other TTS widgets
// Speak lock: coordinate with other TTS Views
useEffect(() => {
if (status === "playing") {
// Announce that we're playing
announcePlayback();
// Poll to detect if another widget starts
// Poll to detect if another View starts
startSpeakLockPolling(() => {
// Another widget took over - pause
// Another View took over - pause
const ctx = audioContextRef.current;
if (ctx && ctx.state === "running") {
ctx.suspend();
@ -1386,32 +1386,32 @@ EMBEDDED_WIDGET_HTML = """<!DOCTYPE html>
);
}
createRoot(document.getElementById('root')).render(<StrictMode><SayWidget /></StrictMode>);
createRoot(document.getElementById('root')).render(<StrictMode><SayView /></StrictMode>);
</script>
</body>
</html>"""
def get_widget_html() -> str:
"""Get the widget HTML, preferring built version from dist/."""
def get_view_html() -> str:
"""Get the View HTML, preferring built version from dist/."""
# Prefer built version from dist/ (local development with npm run build)
dist_path = Path(__file__).parent / "dist" / "mcp-app.html"
if dist_path.exists():
return dist_path.read_text()
# Fallback to embedded widget (for `uv run <url>` or unbundled usage)
return EMBEDDED_WIDGET_HTML
# Fallback to embedded View (for `uv run <url>` or unbundled usage)
return EMBEDDED_VIEW_HTML
# IMPORTANT: all the external domains used by app must be listed
# in the meta.ui.csp.resourceDomains - otherwise they will be blocked by CSP policy
@mcp.resource(
WIDGET_URI,
VIEW_URI,
mime_type="text/html;profile=mcp-app",
meta={"ui": {"csp": {"resourceDomains": ["https://esm.sh", "https://unpkg.com"]}}},
)
def widget() -> str:
"""Widget HTML resource with CSP metadata for external dependencies."""
return get_widget_html()
def view() -> str:
"""View HTML resource with CSP metadata for external dependencies."""
return get_view_html()
# ------------------------------------------------------

View file

@ -69,7 +69,7 @@ html, body {
align-items: center;
justify-content: center;
transition: background 0.2s, opacity 0.2s;
opacity: 0; /* Initially invisible, shown on widget hover */
opacity: 0; /* Initially invisible, shown on View hover */
z-index: 100;
}
@ -91,7 +91,7 @@ html, body {
display: flex;
}
/* Show button on widget hover */
/* Show button on View hover */
.main:hover .fullscreen-btn.available {
opacity: 0.7;
}

View file

@ -134,7 +134,7 @@ React component that:
### Visibility-Based Pause
Animation automatically pauses when the widget scrolls out of view:
Animation automatically pauses when the view scrolls out of view:
- Uses `IntersectionObserver` to track visibility (browser-native, no polling)
- Wraps `requestAnimationFrame` to skip frames when not visible

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<title>Three.js Widget</title>
<title>Three.js View</title>
<link rel="stylesheet" href="/src/global.css">
</head>
<body>

View file

@ -19,7 +19,7 @@ const DIST_DIR = import.meta.filename.endsWith(".ts")
? path.join(import.meta.dirname, "dist")
: import.meta.dirname;
// Default code example for the Three.js widget
// Default code example for the Three.js view
const DEFAULT_THREEJS_CODE = `const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
@ -45,7 +45,7 @@ function animate() {
}
animate();`;
const THREEJS_DOCUMENTATION = `# Three.js Widget Documentation
const THREEJS_DOCUMENTATION = `# Three.js view Documentation
## Available Globals
- \`THREE\` - Three.js library (r181)
@ -191,8 +191,7 @@ export function createServer(): McpServer {
"learn_threejs",
{
title: "Learn Three.js",
description:
"Get documentation and examples for using the Three.js widget",
description: "Get documentation and examples for using the Three.js View",
inputSchema: {},
},
async () => {
@ -207,7 +206,7 @@ export function createServer(): McpServer {
server,
resourceUri,
resourceUri,
{ mimeType: RESOURCE_MIME_TYPE, description: "Three.js Widget UI" },
{ mimeType: RESOURCE_MIME_TYPE, description: "Three.js View UI" },
async (): Promise<ReadResourceResult> => {
const html = await fs.readFile(
path.join(DIST_DIR, "mcp-app.html"),

View file

@ -1,8 +1,8 @@
/**
* Three.js Widget - MCP App Wrapper
* Three.js view - MCP App Wrapper
*
* Generic wrapper that handles MCP App connection and passes all relevant
* props to the actual widget component.
* props to the actual view component.
*/
import type { App, McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";
@ -17,10 +17,10 @@ import "./global.css";
// =============================================================================
/**
* Props passed to the widget component.
* This interface can be reused for other widgets.
* Props passed to the view component.
* This interface can be reused for other views.
*/
export interface WidgetProps<TToolInput = Record<string, unknown>> {
export interface ViewProps<TToolInput = Record<string, unknown>> {
/** Complete tool input (after streaming finishes) */
toolInputs: TToolInput | null;
/** Partial tool input (during streaming) */
@ -55,7 +55,7 @@ function McpAppWrapper() {
const [hostContext, setHostContext] = useState<McpUiHostContext | null>(null);
const { app, error } = useApp({
appInfo: { name: "Three.js Widget", version: "1.0.0" },
appInfo: { name: "Three.js View", version: "1.0.0" },
capabilities: {},
onAppCreated: (app) => {
// Complete tool input (streaming finished)

Some files were not shown because too many files have changed in this diff Show more