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:
parent
9e42ee05a3
commit
4fc71656a3
3 changed files with 381 additions and 393 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
# ------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in a new issue