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:
|
||||
|
||||
- **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
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ export function createServer(): McpServer {
|
|||
},
|
||||
],
|
||||
_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)
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ The viewer supports zoom, navigation, text selection, and fullscreen mode.`,
|
|||
],
|
||||
structuredContent: result,
|
||||
_meta: {
|
||||
widgetUUID: randomUUID(),
|
||||
viewUUID: randomUUID(),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
# ------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue