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
37 changed files with 381 additions and 393 deletions
12
AGENTS.md
12
AGENTS.md
|
|
@ -6,7 +6,7 @@ MCP Apps SDK (`@modelcontextprotocol/ext-apps`) enables MCP servers to display i
|
||||||
|
|
||||||
Key abstractions:
|
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
|
- **Host** - Chat client embedding the iframe, uses `AppBridge` class to proxy MCP requests
|
||||||
- **Server** - MCP server that registers tools/resources with UI metadata
|
- **Server** - MCP server that registers tools/resources with UI metadata
|
||||||
|
|
||||||
|
|
@ -67,14 +67,14 @@ rm -fR package-lock.json node_modules && \
|
||||||
### Protocol Flow
|
### 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
|
1. Host creates iframe with view HTML
|
||||||
2. Guest UI creates `App` instance and calls `connect()` with `PostMessageTransport`
|
2. View creates `App` instance and calls `connect()` with `PostMessageTransport`
|
||||||
3. App sends `ui/initialize` request, receives host capabilities and context
|
3. View sends `ui/initialize` request, receives host capabilities and context
|
||||||
4. Host sends `sendToolInput()` with tool arguments after initialization
|
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
|
6. Host sends `sendToolResult()` when tool execution completes
|
||||||
7. Host calls `teardownResource()` before unmounting iframe
|
7. Host calls `teardownResource()` before unmounting iframe
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ function createServer() {
|
||||||
inputSchema: { userId: z.string() },
|
inputSchema: { userId: z.string() },
|
||||||
annotations: { readOnlyHint: true },
|
annotations: { readOnlyHint: true },
|
||||||
_meta: {
|
_meta: {
|
||||||
"openai/outputTemplate": "ui://widget/cart.html",
|
"openai/outputTemplate": "ui://view/cart.html",
|
||||||
"openai/toolInvocation/invoking": "Loading cart...",
|
"openai/toolInvocation/invoking": "Loading cart...",
|
||||||
"openai/toolInvocation/invoked": "Cart ready",
|
"openai/toolInvocation/invoked": "Cart ready",
|
||||||
"openai/widgetAccessible": true,
|
"openai/widgetAccessible": true,
|
||||||
|
|
@ -89,13 +89,13 @@ function createServer() {
|
||||||
|
|
||||||
// Register UI resource
|
// Register UI resource
|
||||||
server.registerResource(
|
server.registerResource(
|
||||||
"Cart Widget",
|
"Cart View",
|
||||||
"ui://widget/cart.html",
|
"ui://view/cart.html",
|
||||||
{ mimeType: "text/html+skybridge" },
|
{ mimeType: "text/html+skybridge" },
|
||||||
async () => ({
|
async () => ({
|
||||||
contents: [
|
contents: [
|
||||||
{
|
{
|
||||||
uri: "ui://widget/cart.html",
|
uri: "ui://view/cart.html",
|
||||||
mimeType: "text/html+skybridge",
|
mimeType: "text/html+skybridge",
|
||||||
text: getCartHtml(),
|
text: getCartHtml(),
|
||||||
_meta: {
|
_meta: {
|
||||||
|
|
@ -137,7 +137,7 @@ function createServer() {
|
||||||
description: "Display the user's shopping cart",
|
description: "Display the user's shopping cart",
|
||||||
inputSchema: { userId: z.string() },
|
inputSchema: { userId: z.string() },
|
||||||
annotations: { readOnlyHint: true },
|
annotations: { readOnlyHint: true },
|
||||||
_meta: { ui: { resourceUri: "ui://widget/cart.html" } },
|
_meta: { ui: { resourceUri: "ui://view/cart.html" } },
|
||||||
},
|
},
|
||||||
async (args) => {
|
async (args) => {
|
||||||
const cart = await getCart(args.userId);
|
const cart = await getCart(args.userId);
|
||||||
|
|
@ -151,13 +151,13 @@ function createServer() {
|
||||||
// Register UI resource
|
// Register UI resource
|
||||||
registerAppResource(
|
registerAppResource(
|
||||||
server,
|
server,
|
||||||
"Cart Widget",
|
"Cart View",
|
||||||
"ui://widget/cart.html",
|
"ui://view/cart.html",
|
||||||
{ description: "Shopping cart UI" },
|
{ description: "Shopping cart UI" },
|
||||||
async () => ({
|
async () => ({
|
||||||
contents: [
|
contents: [
|
||||||
{
|
{
|
||||||
uri: "ui://widget/cart.html",
|
uri: "ui://view/cart.html",
|
||||||
mimeType: RESOURCE_MIME_TYPE,
|
mimeType: RESOURCE_MIME_TYPE,
|
||||||
text: getCartHtml(),
|
text: getCartHtml(),
|
||||||
_meta: {
|
_meta: {
|
||||||
|
|
|
||||||
|
|
@ -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._
|
_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}
|
{@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._
|
_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}
|
{@includeCode ./patterns.tsx#visibilityBasedPause}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -206,21 +206,17 @@ function hostStylingReact() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Example: Persisting widget state (server-side)
|
* Example: Persisting view state (server-side)
|
||||||
*/
|
*/
|
||||||
function persistWidgetStateServer(
|
function persistViewStateServer(url: string, title: string, pageCount: number) {
|
||||||
url: string,
|
|
||||||
title: string,
|
|
||||||
pageCount: number,
|
|
||||||
) {
|
|
||||||
function toolCallback(): CallToolResult {
|
function toolCallback(): CallToolResult {
|
||||||
//#region persistDataServer
|
//#region persistDataServer
|
||||||
// In your tool callback, include widgetUUID in the result metadata.
|
// In your tool callback, include viewUUID in the result metadata.
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: `Displaying PDF viewer for "${title}"` }],
|
content: [{ type: "text", text: `Displaying PDF viewer for "${title}"` }],
|
||||||
structuredContent: { url, title, pageCount, initialPage: 1 },
|
structuredContent: { url, title, pageCount, initialPage: 1 },
|
||||||
_meta: {
|
_meta: {
|
||||||
widgetUUID: randomUUID(),
|
viewUUID: randomUUID(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
//#endregion persistDataServer
|
//#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
|
//#region persistData
|
||||||
// Store the widgetUUID received from the server
|
// Store the viewUUID received from the server
|
||||||
let widgetUUID: string | undefined;
|
let viewUUID: string | undefined;
|
||||||
|
|
||||||
// Helper to save state to localStorage
|
// Helper to save state to localStorage
|
||||||
function saveState<T>(state: T): void {
|
function saveState<T>(state: T): void {
|
||||||
if (!widgetUUID) return;
|
if (!viewUUID) return;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(widgetUUID, JSON.stringify(state));
|
localStorage.setItem(viewUUID, JSON.stringify(state));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to save widget state:", err);
|
console.error("Failed to save view state:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to load state from localStorage
|
// Helper to load state from localStorage
|
||||||
function loadState<T>(): T | null {
|
function loadState<T>(): T | null {
|
||||||
if (!widgetUUID) return null;
|
if (!viewUUID) return null;
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(widgetUUID);
|
const saved = localStorage.getItem(viewUUID);
|
||||||
return saved ? (JSON.parse(saved) as T) : null;
|
return saved ? (JSON.parse(saved) as T) : null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load widget state:", err);
|
console.error("Failed to load view state:", err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Receive widgetUUID from the tool result
|
// Receive viewUUID from the tool result
|
||||||
app.ontoolresult = (result) => {
|
app.ontoolresult = (result) => {
|
||||||
widgetUUID = result._meta?.widgetUUID
|
viewUUID = result._meta?.viewUUID
|
||||||
? String(result._meta.widgetUUID)
|
? String(result._meta.viewUUID)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Restore any previously saved state
|
// 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 });
|
// e.g., saveState({ currentPage: 5 });
|
||||||
//#endregion persistData
|
//#endregion persistData
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Example: Pausing computation-heavy widgets when out of view
|
* Example: Pausing computation-heavy views when out of view
|
||||||
*/
|
*/
|
||||||
function visibilityBasedPause(
|
function visibilityBasedPause(
|
||||||
app: App,
|
app: App,
|
||||||
|
|
@ -284,7 +280,7 @@ function visibilityBasedPause(
|
||||||
animation: { play: () => void; pause: () => void },
|
animation: { play: () => void; pause: () => void },
|
||||||
) {
|
) {
|
||||||
//#region visibilityBasedPause
|
//#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) => {
|
const observer = new IntersectionObserver((entries) => {
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
|
|
@ -296,7 +292,7 @@ function visibilityBasedPause(
|
||||||
});
|
});
|
||||||
observer.observe(container);
|
observer.observe(container);
|
||||||
|
|
||||||
// Clean up when the host tears down the widget
|
// Clean up when the host tears down the view
|
||||||
app.onteardown = async () => {
|
app.onteardown = async () => {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
animation.pause();
|
animation.pause();
|
||||||
|
|
@ -310,6 +306,6 @@ void chunkedDataServer;
|
||||||
void chunkedDataClient;
|
void chunkedDataClient;
|
||||||
void hostStylingVanillaJs;
|
void hostStylingVanillaJs;
|
||||||
void hostStylingReact;
|
void hostStylingReact;
|
||||||
void persistWidgetStateServer;
|
void persistViewStateServer;
|
||||||
void persistWidgetState;
|
void persistViewState;
|
||||||
void visibilityBasedPause;
|
void visibilityBasedPause;
|
||||||
|
|
|
||||||
|
|
@ -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
|
// sending requests immediately after the initialization handshake, so any
|
||||||
// handlers registered after connect() might miss early requests.
|
// handlers registered after connect() might miss early requests.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ const PROXY_READY_NOTIFICATION: McpUiSandboxProxyReadyNotification["method"] =
|
||||||
// Message relay: This Sandbox (outer iframe) acts as a bidirectional bridge,
|
// Message relay: This Sandbox (outer iframe) acts as a bidirectional bridge,
|
||||||
// forwarding messages between:
|
// 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
|
// Reason: the parent window and inner iframe have different origins and can't
|
||||||
// communicate directly, so the outer iframe forwards messages in both
|
// 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
|
// Special case: The "ui/notifications/sandbox-proxy-ready" message is
|
||||||
// intercepted here (not relayed) because the Sandbox uses it to configure and
|
// 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
|
// 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.
|
// 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.
|
// Use specific origin instead of "*" to ensure only the expected host receives this.
|
||||||
window.parent.postMessage({
|
window.parent.postMessage({
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,7 @@ export function createServer(): McpServer {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
_meta: {
|
_meta: {
|
||||||
widgetUUID: randomUUID(),
|
viewUUID: randomUUID(),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
// Track whether tool input has been received (to know if we should restore persisted state)
|
||||||
let hasReceivedToolInput = false;
|
let hasReceivedToolInput = false;
|
||||||
|
|
||||||
let widgetUUID: string | undefined = undefined;
|
let viewUUID: string | undefined = undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persisted camera state for localStorage
|
* Persisted camera state for localStorage
|
||||||
|
|
@ -122,7 +122,7 @@ function schedulePersistViewState(cesiumViewer: any): void {
|
||||||
* Persist current view state to localStorage
|
* Persist current view state to localStorage
|
||||||
*/
|
*/
|
||||||
function persistViewState(cesiumViewer: any): void {
|
function persistViewState(cesiumViewer: any): void {
|
||||||
if (!widgetUUID) {
|
if (!viewUUID) {
|
||||||
log.info("No storage key available, skipping view persistence");
|
log.info("No storage key available, skipping view persistence");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -132,8 +132,8 @@ function persistViewState(cesiumViewer: any): void {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const value = JSON.stringify(state);
|
const value = JSON.stringify(state);
|
||||||
localStorage.setItem(widgetUUID, value);
|
localStorage.setItem(viewUUID, value);
|
||||||
log.info("Persisted view state:", widgetUUID, value);
|
log.info("Persisted view state:", viewUUID, value);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.warn("Failed to persist view state:", e);
|
log.warn("Failed to persist view state:", e);
|
||||||
}
|
}
|
||||||
|
|
@ -143,10 +143,10 @@ function persistViewState(cesiumViewer: any): void {
|
||||||
* Load persisted view state from localStorage
|
* Load persisted view state from localStorage
|
||||||
*/
|
*/
|
||||||
function loadPersistedViewState(): PersistedCameraState | null {
|
function loadPersistedViewState(): PersistedCameraState | null {
|
||||||
if (!widgetUUID) return null;
|
if (!viewUUID) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(widgetUUID);
|
const stored = localStorage.getItem(viewUUID);
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
console.info("No persisted view state found");
|
console.info("No persisted view state found");
|
||||||
return null;
|
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) => {
|
app.ontoolresult = async (result) => {
|
||||||
widgetUUID = result._meta?.widgetUUID
|
viewUUID = result._meta?.viewUUID ? String(result._meta.viewUUID) : undefined;
|
||||||
? String(result._meta.widgetUUID)
|
log.info("Tool result received, viewUUID:", viewUUID);
|
||||||
: undefined;
|
|
||||||
log.info("Tool result received, widgetUUID:", widgetUUID);
|
|
||||||
|
|
||||||
// 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
|
// This overrides the tool input position if a saved state exists
|
||||||
if (viewer && widgetUUID) {
|
if (viewer && viewUUID) {
|
||||||
const restored = restorePersistedView(viewer);
|
const restored = restorePersistedView(viewer);
|
||||||
if (restored) {
|
if (restored) {
|
||||||
log.info("Restored persisted view from tool result handler");
|
log.info("Restored persisted view from tool result handler");
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,7 @@ The viewer supports zoom, navigation, text selection, and fullscreen mode.`,
|
||||||
],
|
],
|
||||||
structuredContent: result,
|
structuredContent: result,
|
||||||
_meta: {
|
_meta: {
|
||||||
widgetUUID: randomUUID(),
|
viewUUID: randomUUID(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ let totalPages = 0;
|
||||||
let scale = 1.0;
|
let scale = 1.0;
|
||||||
let pdfUrl = "";
|
let pdfUrl = "";
|
||||||
let pdfTitle: string | undefined;
|
let pdfTitle: string | undefined;
|
||||||
let widgetUUID: string | undefined;
|
let viewUUID: string | undefined;
|
||||||
let currentRenderTask: { cancel: () => void } | null = null;
|
let currentRenderTask: { cancel: () => void } | null = null;
|
||||||
|
|
||||||
// DOM Elements
|
// DOM Elements
|
||||||
|
|
@ -404,10 +404,10 @@ async function renderPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveCurrentPage() {
|
function saveCurrentPage() {
|
||||||
log.info("saveCurrentPage: key=", widgetUUID, "page=", currentPage);
|
log.info("saveCurrentPage: key=", viewUUID, "page=", currentPage);
|
||||||
if (widgetUUID) {
|
if (viewUUID) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(widgetUUID, String(currentPage));
|
localStorage.setItem(viewUUID, String(currentPage));
|
||||||
log.info("saveCurrentPage: saved successfully");
|
log.info("saveCurrentPage: saved successfully");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error("saveCurrentPage: error", err);
|
log.error("saveCurrentPage: error", err);
|
||||||
|
|
@ -416,10 +416,10 @@ function saveCurrentPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadSavedPage(): number | null {
|
function loadSavedPage(): number | null {
|
||||||
log.info("loadSavedPage: key=", widgetUUID);
|
log.info("loadSavedPage: key=", viewUUID);
|
||||||
if (!widgetUUID) return null;
|
if (!viewUUID) return null;
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(widgetUUID);
|
const saved = localStorage.getItem(viewUUID);
|
||||||
log.info("loadSavedPage: saved value=", saved);
|
log.info("loadSavedPage: saved value=", saved);
|
||||||
if (saved) {
|
if (saved) {
|
||||||
const page = parseInt(saved, 10);
|
const page = parseInt(saved, 10);
|
||||||
|
|
@ -706,9 +706,7 @@ app.ontoolresult = async (result) => {
|
||||||
pdfUrl = parsed.url;
|
pdfUrl = parsed.url;
|
||||||
pdfTitle = parsed.title;
|
pdfTitle = parsed.title;
|
||||||
totalPages = parsed.pageCount;
|
totalPages = parsed.pageCount;
|
||||||
widgetUUID = result._meta?.widgetUUID
|
viewUUID = result._meta?.viewUUID ? String(result._meta.viewUUID) : undefined;
|
||||||
? String(result._meta.widgetUUID)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// Restore saved page or use initial page
|
// Restore saved page or use initial page
|
||||||
const savedPage = loadSavedPage();
|
const savedPage = loadSavedPage();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# QR Code MCP Server
|
# 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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
@ -33,7 +33,7 @@ Then add to your MCP client configuration (stdio transport), replacing the path
|
||||||
|
|
||||||
- Generate QR codes from any text or URL
|
- Generate QR codes from any text or URL
|
||||||
- Customizable colors, size, and error correction
|
- 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)
|
- Supports both HTTP (for web clients) and stdio (for MCP clients)
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
@ -151,19 +151,19 @@ Generate a QR code with optional customization.
|
||||||
```
|
```
|
||||||
qr-server/
|
qr-server/
|
||||||
├── server.py # MCP server (FastMCP + uvicorn, deps inline via PEP 723)
|
├── server.py # MCP server (FastMCP + uvicorn, deps inline via PEP 723)
|
||||||
├── widget.html # Interactive UI widget
|
├── view.html # Interactive UI view
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
### Protocol
|
### 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
|
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
|
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
|
## Dependencies
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,14 +22,14 @@ from mcp.server.fastmcp import FastMCP
|
||||||
from mcp import types
|
from mcp import types
|
||||||
from starlette.middleware.cors import CORSMiddleware
|
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
|
HOST = os.environ.get("HOST", "0.0.0.0") # 0.0.0.0 for Docker compatibility
|
||||||
PORT = int(os.environ.get("PORT", "3108"))
|
PORT = int(os.environ.get("PORT", "3108"))
|
||||||
|
|
||||||
mcp = FastMCP("QR Code Server")
|
mcp = FastMCP("QR Code Server")
|
||||||
|
|
||||||
# Embedded widget HTML for self-contained usage (uv run <url> or unbundled)
|
# Embedded View HTML for self-contained usage (uv run <url> or unbundled)
|
||||||
EMBEDDED_WIDGET_HTML = """<!DOCTYPE html>
|
EMBEDDED_VIEW_HTML = """<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta name="color-scheme" content="light dark">
|
<meta name="color-scheme" content="light dark">
|
||||||
|
|
@ -60,7 +60,7 @@ EMBEDDED_WIDGET_HTML = """<!DOCTYPE html>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { App } from "https://unpkg.com/@modelcontextprotocol/ext-apps@0.4.0/app-with-deps";
|
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 }) => {
|
app.ontoolresult = ({ content }) => {
|
||||||
const img = content?.find(c => c.type === 'image');
|
const img = content?.find(c => c.type === 'image');
|
||||||
|
|
@ -100,8 +100,8 @@ EMBEDDED_WIDGET_HTML = """<!DOCTYPE html>
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(meta={
|
@mcp.tool(meta={
|
||||||
"ui":{"resourceUri": WIDGET_URI},
|
"ui":{"resourceUri": VIEW_URI},
|
||||||
"ui/resourceUri": WIDGET_URI, # legacy support
|
"ui/resourceUri": VIEW_URI, # legacy support
|
||||||
})
|
})
|
||||||
def generate_qr(
|
def generate_qr(
|
||||||
text: str = "https://modelcontextprotocol.io",
|
text: str = "https://modelcontextprotocol.io",
|
||||||
|
|
@ -147,13 +147,13 @@ def generate_qr(
|
||||||
# IMPORTANT: all the external domains used by app must be listed
|
# 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
|
# in the meta.ui.csp.resourceDomains - otherwise they will be blocked by CSP policy
|
||||||
@mcp.resource(
|
@mcp.resource(
|
||||||
WIDGET_URI,
|
VIEW_URI,
|
||||||
mime_type="text/html;profile=mcp-app",
|
mime_type="text/html;profile=mcp-app",
|
||||||
meta={"ui": {"csp": {"resourceDomains": ["https://unpkg.com"]}}},
|
meta={"ui": {"csp": {"resourceDomains": ["https://unpkg.com"]}}},
|
||||||
)
|
)
|
||||||
def widget() -> str:
|
def view() -> str:
|
||||||
"""Widget HTML resource with CSP metadata for external dependencies."""
|
"""View HTML resource with CSP metadata for external dependencies."""
|
||||||
return EMBEDDED_WIDGET_HTML
|
return EMBEDDED_VIEW_HTML
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
if "--stdio" in sys.argv:
|
if "--stdio" in sys.argv:
|
||||||
|
|
|
||||||
|
|
@ -30,13 +30,13 @@ Add to your MCP client configuration (stdio transport):
|
||||||
This example showcases several MCP App capabilities:
|
This example showcases several MCP App capabilities:
|
||||||
|
|
||||||
- **Single-file executable**: Python server with embedded React UI - no build step required
|
- **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)
|
- **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
|
- **Native theming**: Uses CSS variables for automatic dark/light mode adaptation
|
||||||
- **Fullscreen mode**: Toggle fullscreen via `requestDisplayMode()` API, press Escape to exit
|
- **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
|
- **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 widget, not the model
|
- **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
|
- **External links** (`openLink`): Attribution popup uses `app.openLink()` to open external URLs
|
||||||
- **CSP metadata**: Resource declares required domains (`esm.sh`) for in-browser transpilation
|
- **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:
|
The entire server is contained in a single `server.py` file:
|
||||||
|
|
||||||
1. **`say` tool**: Public tool that triggers the widget with text to speak
|
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 widget
|
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 widget**: Uses [Babel standalone](https://babeljs.io/docs/babel-standalone) for in-browser JSX transpilation - no build step needed
|
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
|
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
|
- Receives streaming text via `ontoolinputpartial` callback
|
||||||
- Incrementally sends new text to the server as it arrives (via `add_tts_text`)
|
- Incrementally sends new text to the server as it arrives (via `add_tts_text`)
|
||||||
- Polls for generated audio chunks while TTS runs in parallel
|
- Polls for generated audio chunks while TTS runs in parallel
|
||||||
- Plays audio via Web Audio API with synchronized text highlighting
|
- 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`
|
1. **Unique view IDs**: Each view receives a UUID via `toolResult._meta.viewUUID`
|
||||||
2. **Announce on Play**: When starting, a widget writes `{uuid, timestamp}` to `localStorage["mcp-tts-playing"]`
|
2. **Announce on Play**: When starting, a view writes `{uuid, timestamp}` to `localStorage["mcp-tts-playing"]`
|
||||||
3. **Poll for Conflicts**: Every 200ms, playing widgets check if another widget took the lock
|
3. **Poll for Conflicts**: Every 200ms, playing views check if another view took the lock
|
||||||
4. **Yield Gracefully**: If another widget started playing, pause and yield
|
4. **Yield Gracefully**: If another view started playing, pause and yield
|
||||||
5. **Clean Up**: On pause/finish, clear the lock (only if owned)
|
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
|
## TODO
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="color-scheme" content="light dark">
|
<meta name="color-scheme" content="light dark">
|
||||||
<title>Say Widget</title>
|
<title>Say View</title>
|
||||||
<link rel="stylesheet" href="/src/global.css">
|
<link rel="stylesheet" href="/src/global.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -12,17 +12,17 @@
|
||||||
Say Demo - MCP App for streaming text-to-speech.
|
Say Demo - MCP App for streaming text-to-speech.
|
||||||
|
|
||||||
This MCP server provides a "say" tool that speaks text using TTS.
|
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:
|
Architecture:
|
||||||
- The `say` tool itself is a no-op - it just triggers the widget
|
- The `say` tool itself is a no-op - it just triggers the view
|
||||||
- The widget uses `ontoolinputpartial` to receive text as it streams
|
- The view uses `ontoolinputpartial` to receive text as it streams
|
||||||
- Widget calls private tools to create TTS queue, add text, and poll audio
|
- view calls private tools to create TTS queue, add text, and poll audio
|
||||||
- Audio plays in the widget using Web Audio API
|
- Audio plays in the view using Web Audio API
|
||||||
- Model context updates show playback progress to the LLM
|
- Model context updates show playback progress to the LLM
|
||||||
- Native theming adapts to dark/light mode automatically
|
- Native theming adapts to dark/light mode automatically
|
||||||
- Fullscreen mode with Escape key to exit
|
- Fullscreen mode with Escape key to exit
|
||||||
- Multi-widget speak lock coordinates playback across instances
|
- Multi-View speak lock coordinates playback across instances
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
# Start the MCP server
|
# Start the MCP server
|
||||||
|
|
@ -56,7 +56,7 @@ from pocket_tts.default_parameters import DEFAULT_AUDIO_PROMPT
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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")
|
HOST = os.environ.get("HOST", "0.0.0.0")
|
||||||
PORT = int(os.environ.get("PORT", "3109"))
|
PORT = int(os.environ.get("PORT", "3109"))
|
||||||
|
|
||||||
|
|
@ -167,8 +167,8 @@ def list_voices() -> list[types.TextContent]:
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(meta={
|
@mcp.tool(meta={
|
||||||
"ui":{"resourceUri": WIDGET_URI},
|
"ui":{"resourceUri": VIEW_URI},
|
||||||
"ui/resourceUri": WIDGET_URI, # legacy support
|
"ui/resourceUri": VIEW_URI, # legacy support
|
||||||
})
|
})
|
||||||
def say(
|
def say(
|
||||||
text: Annotated[str, Field(description="The English text to speak aloud")] = DEFAULT_TEXT,
|
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.
|
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)
|
# Generate a unique ID for this view instance (used for speak lock coordination)
|
||||||
widget_uuid = uuid.uuid4().hex[:12]
|
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:
|
# The tool exists to:
|
||||||
# 1. Trigger the widget to load
|
# 1. Trigger the view to load
|
||||||
# 2. Provide the resourceUri metadata
|
# 2. Provide the resourceUri metadata
|
||||||
# 3. Show the final text in the tool result
|
# 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(
|
return [types.TextContent(
|
||||||
type="text",
|
type="text",
|
||||||
text=f"Displayed a TTS widget with voice '{voice}'. Click to play/pause, use toolbar to restart or fullscreen.",
|
text=f"Displayed a TTS view with voice '{voice}'. Click to play/pause, use toolbar to restart or fullscreen.",
|
||||||
_meta={"widgetUUID": widget_uuid},
|
_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:]
|
new_chunks = state.audio_chunks[state.chunks_delivered:]
|
||||||
state.chunks_delivered = len(state.audio_chunks)
|
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)
|
done = (state.status == "complete" or state.status == "error") and state.chunks_delivered >= len(state.audio_chunks)
|
||||||
|
|
||||||
response = {
|
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
|
# Uses Babel standalone for in-browser JSX transpilation
|
||||||
# This is a copy of widget.html - keep them in sync!
|
# This is a copy of view.html - keep them in sync!
|
||||||
EMBEDDED_WIDGET_HTML = """<!DOCTYPE html>
|
EMBEDDED_VIEW_HTML = """<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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 src="https://unpkg.com/@babel/standalone@7.26.10/babel.min.js"></script>
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
|
|
@ -784,7 +784,7 @@ EMBEDDED_WIDGET_HTML = """<!DOCTYPE html>
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { useApp } from '@modelcontextprotocol/ext-apps/react';
|
import { useApp } from '@modelcontextprotocol/ext-apps/react';
|
||||||
|
|
||||||
function SayWidget() {
|
function SayView() {
|
||||||
const [hostContext, setHostContext] = useState(undefined);
|
const [hostContext, setHostContext] = useState(undefined);
|
||||||
const [displayText, setDisplayText] = useState("");
|
const [displayText, setDisplayText] = useState("");
|
||||||
const [charPosition, setCharPosition] = useState(0);
|
const [charPosition, setCharPosition] = useState(0);
|
||||||
|
|
@ -796,7 +796,7 @@ EMBEDDED_WIDGET_HTML = """<!DOCTYPE html>
|
||||||
const [showInfo, setShowInfo] = useState(false);
|
const [showInfo, setShowInfo] = useState(false);
|
||||||
|
|
||||||
const voiceRef = useRef("cosette"); // Current voice, updated from tool input
|
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 speakLockIntervalRef = useRef(null); // Polling interval for speak lock
|
||||||
const queueIdRef = useRef(null);
|
const queueIdRef = useRef(null);
|
||||||
const audioContextRef = useRef(null);
|
const audioContextRef = useRef(null);
|
||||||
|
|
@ -816,22 +816,22 @@ EMBEDDED_WIDGET_HTML = """<!DOCTYPE html>
|
||||||
const initQueuePromiseRef = useRef(null);
|
const initQueuePromiseRef = useRef(null);
|
||||||
const pendingModelContextUpdateRef = 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 SPEAK_LOCK_KEY = "mcp-tts-playing";
|
||||||
|
|
||||||
const announcePlayback = useCallback(() => {
|
const announcePlayback = useCallback(() => {
|
||||||
if (!widgetUuidRef.current) return;
|
if (!viewUuidRef.current) return;
|
||||||
localStorage.setItem(SPEAK_LOCK_KEY, JSON.stringify({
|
localStorage.setItem(SPEAK_LOCK_KEY, JSON.stringify({
|
||||||
uuid: widgetUuidRef.current,
|
uuid: viewUuidRef.current,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clearSpeakLock = useCallback(() => {
|
const clearSpeakLock = useCallback(() => {
|
||||||
if (!widgetUuidRef.current) return;
|
if (!viewUuidRef.current) return;
|
||||||
try {
|
try {
|
||||||
const current = JSON.parse(localStorage.getItem(SPEAK_LOCK_KEY) || "null");
|
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);
|
localStorage.removeItem(SPEAK_LOCK_KEY);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
@ -842,9 +842,9 @@ EMBEDDED_WIDGET_HTML = """<!DOCTYPE html>
|
||||||
speakLockIntervalRef.current = setInterval(() => {
|
speakLockIntervalRef.current = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
const current = JSON.parse(localStorage.getItem(SPEAK_LOCK_KEY) || "null");
|
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
|
// Someone else started playing - yield
|
||||||
console.log('[TTS] Another widget started playing, pausing');
|
console.log('[TTS] Another view started playing, pausing');
|
||||||
onStolenCallback();
|
onStolenCallback();
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
@ -1161,7 +1161,7 @@ EMBEDDED_WIDGET_HTML = """<!DOCTYPE html>
|
||||||
}, [displayMode]);
|
}, [displayMode]);
|
||||||
|
|
||||||
const { app, error } = useApp({
|
const { app, error } = useApp({
|
||||||
appInfo: { name: "Say Widget", version: "1.0.0" },
|
appInfo: { name: "Say View", version: "1.0.0" },
|
||||||
capabilities: {},
|
capabilities: {},
|
||||||
onHostContextChanged: (ctx) => {
|
onHostContextChanged: (ctx) => {
|
||||||
if (ctx.availableDisplayModes?.includes("fullscreen")) {
|
if (ctx.availableDisplayModes?.includes("fullscreen")) {
|
||||||
|
|
@ -1219,11 +1219,11 @@ EMBEDDED_WIDGET_HTML = """<!DOCTYPE html>
|
||||||
app.ontoolresult = async (params) => {
|
app.ontoolresult = async (params) => {
|
||||||
console.log('[TTS] ontoolresult called, queueId:', queueIdRef.current);
|
console.log('[TTS] ontoolresult called, queueId:', queueIdRef.current);
|
||||||
fullTextRef.current = lastTextRef.current;
|
fullTextRef.current = lastTextRef.current;
|
||||||
// Read widget UUID from tool result _meta for speak lock coordination
|
// Read View UUID from tool result _meta for speak lock coordination
|
||||||
const resultUuid = params.content?.[0]?._meta?.widgetUUID;
|
const resultUuid = params.content?.[0]?._meta?.viewUUID;
|
||||||
if (resultUuid) {
|
if (resultUuid) {
|
||||||
widgetUuidRef.current = resultUuid;
|
viewUuidRef.current = resultUuid;
|
||||||
console.log('[TTS] Widget UUID:', resultUuid);
|
console.log('[TTS] View UUID:', resultUuid);
|
||||||
}
|
}
|
||||||
if (queueIdRef.current) {
|
if (queueIdRef.current) {
|
||||||
console.log('[TTS] Calling end_tts_queue for:', queueIdRef.current);
|
console.log('[TTS] Calling end_tts_queue for:', queueIdRef.current);
|
||||||
|
|
@ -1267,14 +1267,14 @@ EMBEDDED_WIDGET_HTML = """<!DOCTYPE html>
|
||||||
}
|
}
|
||||||
}, [app]);
|
}, [app]);
|
||||||
|
|
||||||
// Speak lock: coordinate with other TTS widgets
|
// Speak lock: coordinate with other TTS Views
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === "playing") {
|
if (status === "playing") {
|
||||||
// Announce that we're playing
|
// Announce that we're playing
|
||||||
announcePlayback();
|
announcePlayback();
|
||||||
// Poll to detect if another widget starts
|
// Poll to detect if another View starts
|
||||||
startSpeakLockPolling(() => {
|
startSpeakLockPolling(() => {
|
||||||
// Another widget took over - pause
|
// Another View took over - pause
|
||||||
const ctx = audioContextRef.current;
|
const ctx = audioContextRef.current;
|
||||||
if (ctx && ctx.state === "running") {
|
if (ctx && ctx.state === "running") {
|
||||||
ctx.suspend();
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
def get_widget_html() -> str:
|
def get_view_html() -> str:
|
||||||
"""Get the widget HTML, preferring built version from dist/."""
|
"""Get the View HTML, preferring built version from dist/."""
|
||||||
# Prefer built version from dist/ (local development with npm run build)
|
# Prefer built version from dist/ (local development with npm run build)
|
||||||
dist_path = Path(__file__).parent / "dist" / "mcp-app.html"
|
dist_path = Path(__file__).parent / "dist" / "mcp-app.html"
|
||||||
if dist_path.exists():
|
if dist_path.exists():
|
||||||
return dist_path.read_text()
|
return dist_path.read_text()
|
||||||
# Fallback to embedded widget (for `uv run <url>` or unbundled usage)
|
# Fallback to embedded View (for `uv run <url>` or unbundled usage)
|
||||||
return EMBEDDED_WIDGET_HTML
|
return EMBEDDED_VIEW_HTML
|
||||||
|
|
||||||
|
|
||||||
# IMPORTANT: all the external domains used by app must be listed
|
# 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
|
# in the meta.ui.csp.resourceDomains - otherwise they will be blocked by CSP policy
|
||||||
@mcp.resource(
|
@mcp.resource(
|
||||||
WIDGET_URI,
|
VIEW_URI,
|
||||||
mime_type="text/html;profile=mcp-app",
|
mime_type="text/html;profile=mcp-app",
|
||||||
meta={"ui": {"csp": {"resourceDomains": ["https://esm.sh", "https://unpkg.com"]}}},
|
meta={"ui": {"csp": {"resourceDomains": ["https://esm.sh", "https://unpkg.com"]}}},
|
||||||
)
|
)
|
||||||
def widget() -> str:
|
def view() -> str:
|
||||||
"""Widget HTML resource with CSP metadata for external dependencies."""
|
"""View HTML resource with CSP metadata for external dependencies."""
|
||||||
return get_widget_html()
|
return get_view_html()
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------
|
# ------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ html, body {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: background 0.2s, opacity 0.2s;
|
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;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,7 +91,7 @@ html, body {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Show button on widget hover */
|
/* Show button on View hover */
|
||||||
.main:hover .fullscreen-btn.available {
|
.main:hover .fullscreen-btn.available {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ React component that:
|
||||||
|
|
||||||
### Visibility-Based Pause
|
### 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)
|
- Uses `IntersectionObserver` to track visibility (browser-native, no polling)
|
||||||
- Wraps `requestAnimationFrame` to skip frames when not visible
|
- Wraps `requestAnimationFrame` to skip frames when not visible
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="color-scheme" content="light dark">
|
<meta name="color-scheme" content="light dark">
|
||||||
<title>Three.js Widget</title>
|
<title>Three.js View</title>
|
||||||
<link rel="stylesheet" href="/src/global.css">
|
<link rel="stylesheet" href="/src/global.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ const DIST_DIR = import.meta.filename.endsWith(".ts")
|
||||||
? path.join(import.meta.dirname, "dist")
|
? path.join(import.meta.dirname, "dist")
|
||||||
: import.meta.dirname;
|
: 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 DEFAULT_THREEJS_CODE = `const scene = new THREE.Scene();
|
||||||
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
|
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
|
||||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
||||||
|
|
@ -45,7 +45,7 @@ function animate() {
|
||||||
}
|
}
|
||||||
animate();`;
|
animate();`;
|
||||||
|
|
||||||
const THREEJS_DOCUMENTATION = `# Three.js Widget Documentation
|
const THREEJS_DOCUMENTATION = `# Three.js view Documentation
|
||||||
|
|
||||||
## Available Globals
|
## Available Globals
|
||||||
- \`THREE\` - Three.js library (r181)
|
- \`THREE\` - Three.js library (r181)
|
||||||
|
|
@ -191,8 +191,7 @@ export function createServer(): McpServer {
|
||||||
"learn_threejs",
|
"learn_threejs",
|
||||||
{
|
{
|
||||||
title: "Learn Three.js",
|
title: "Learn Three.js",
|
||||||
description:
|
description: "Get documentation and examples for using the Three.js View",
|
||||||
"Get documentation and examples for using the Three.js widget",
|
|
||||||
inputSchema: {},
|
inputSchema: {},
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
|
|
@ -207,7 +206,7 @@ export function createServer(): McpServer {
|
||||||
server,
|
server,
|
||||||
resourceUri,
|
resourceUri,
|
||||||
resourceUri,
|
resourceUri,
|
||||||
{ mimeType: RESOURCE_MIME_TYPE, description: "Three.js Widget UI" },
|
{ mimeType: RESOURCE_MIME_TYPE, description: "Three.js View UI" },
|
||||||
async (): Promise<ReadResourceResult> => {
|
async (): Promise<ReadResourceResult> => {
|
||||||
const html = await fs.readFile(
|
const html = await fs.readFile(
|
||||||
path.join(DIST_DIR, "mcp-app.html"),
|
path.join(DIST_DIR, "mcp-app.html"),
|
||||||
|
|
|
||||||
|
|
@ -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
|
* 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 type { App, McpUiHostContext } from "@modelcontextprotocol/ext-apps";
|
||||||
import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";
|
import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";
|
||||||
|
|
@ -17,10 +17,10 @@ import "./global.css";
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props passed to the widget component.
|
* Props passed to the view component.
|
||||||
* This interface can be reused for other widgets.
|
* 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) */
|
/** Complete tool input (after streaming finishes) */
|
||||||
toolInputs: TToolInput | null;
|
toolInputs: TToolInput | null;
|
||||||
/** Partial tool input (during streaming) */
|
/** Partial tool input (during streaming) */
|
||||||
|
|
@ -55,7 +55,7 @@ function McpAppWrapper() {
|
||||||
const [hostContext, setHostContext] = useState<McpUiHostContext | null>(null);
|
const [hostContext, setHostContext] = useState<McpUiHostContext | null>(null);
|
||||||
|
|
||||||
const { app, error } = useApp({
|
const { app, error } = useApp({
|
||||||
appInfo: { name: "Three.js Widget", version: "1.0.0" },
|
appInfo: { name: "Three.js View", version: "1.0.0" },
|
||||||
capabilities: {},
|
capabilities: {},
|
||||||
onAppCreated: (app) => {
|
onAppCreated: (app) => {
|
||||||
// Complete tool input (streaming finished)
|
// Complete tool input (streaming finished)
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue