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
3 changed files with 381 additions and 393 deletions

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()
# ------------------------------------------------------