diff --git a/examples/video-resource-server/mcp-app.html b/examples/video-resource-server/mcp-app.html index 437c0205c..93c8cbba6 100644 --- a/examples/video-resource-server/mcp-app.html +++ b/examples/video-resource-server/mcp-app.html @@ -21,6 +21,15 @@
+ + diff --git a/examples/video-resource-server/server.ts b/examples/video-resource-server/server.ts index 239766583..2cb317e41 100644 --- a/examples/video-resource-server/server.ts +++ b/examples/video-resource-server/server.ts @@ -69,7 +69,16 @@ export function createServer(): McpServer { server.registerResource( "video", - new ResourceTemplate("videos://{id}", { list: undefined }), + new ResourceTemplate("videos://{id}", { + list: async () => ({ + resources: Object.entries(VIDEO_LIBRARY).map(([id, video]) => ({ + uri: `videos://${id}`, + name: `video-${id}`, + description: `Video: ${video.description}`, + mimeType: "video/mp4", + })), + }), + }), { description: "Video served via MCP resource (base64 blob)", mimeType: "video/mp4", diff --git a/examples/video-resource-server/src/global.css b/examples/video-resource-server/src/global.css index b53cf5eb6..d94a599cd 100644 --- a/examples/video-resource-server/src/global.css +++ b/examples/video-resource-server/src/global.css @@ -1,10 +1,94 @@ +:root { + color-scheme: light dark; + + /* + * Fallbacks for host style variables used by this app. + * The host may provide these (and many more) via the host context. + */ + --color-text-primary: light-dark(#1f2937, #f3f4f6); + --color-text-inverse: light-dark(#f3f4f6, #1f2937); + --color-text-info: light-dark(#1d4ed8, #60a5fa); + --color-background-primary: light-dark(#ffffff, #1a1a1a); + --color-background-inverse: light-dark(#1a1a1a, #ffffff); + --color-background-info: light-dark(#eff6ff, #1e3a5f); + --color-ring-primary: light-dark(#3b82f6, #60a5fa); + --border-radius-md: 6px; + --border-width-regular: 1px; + --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + --font-weight-normal: 400; + --font-weight-bold: 700; + --font-text-md-size: 1rem; + --font-text-md-line-height: 1.5; + --font-heading-3xl-size: 2.25rem; + --font-heading-3xl-line-height: 1.1; + --font-heading-2xl-size: 1.875rem; + --font-heading-2xl-line-height: 1.2; + --font-heading-xl-size: 1.5rem; + --font-heading-xl-line-height: 1.25; + --font-heading-lg-size: 1.25rem; + --font-heading-lg-line-height: 1.3; + --font-heading-md-size: 1rem; + --font-heading-md-line-height: 1.4; + --font-heading-sm-size: 0.875rem; + --font-heading-sm-line-height: 1.4; + + /* Spacing derived from host typography */ + --spacing-unit: var(--font-text-md-size); + --spacing-xs: calc(var(--spacing-unit) * 0.25); + --spacing-sm: calc(var(--spacing-unit) * 0.5); + --spacing-md: var(--spacing-unit); + --spacing-lg: calc(var(--spacing-unit) * 1.5); + + /* App accent color (customize for your brand) */ + --color-accent: #2563eb; + --color-text-on-accent: #ffffff; +} + * { box-sizing: border-box; } html, body { - font-family: system-ui, -apple-system, sans-serif; - font-size: 1rem; + font-family: var(--font-sans); + font-size: var(--font-text-md-size); + font-weight: var(--font-weight-normal); + line-height: var(--font-text-md-line-height); + color: var(--color-text-primary); margin: 0; padding: 0; } + +h1 { + font-size: var(--font-heading-3xl-size); + line-height: var(--font-heading-3xl-line-height); +} +h2 { + font-size: var(--font-heading-2xl-size); + line-height: var(--font-heading-2xl-line-height); +} +h3 { + font-size: var(--font-heading-xl-size); + line-height: var(--font-heading-xl-line-height); +} +h4 { + font-size: var(--font-heading-lg-size); + line-height: var(--font-heading-lg-line-height); +} +h5 { + font-size: var(--font-heading-md-size); + line-height: var(--font-heading-md-line-height); +} +h6 { + font-size: var(--font-heading-sm-size); + line-height: var(--font-heading-sm-line-height); +} + +code, pre, kbd { + font-family: var(--font-mono); + font-size: 1em; +} + +b, strong { + font-weight: var(--font-weight-bold); +} diff --git a/examples/video-resource-server/src/mcp-app.css b/examples/video-resource-server/src/mcp-app.css index 331255387..34139744e 100644 --- a/examples/video-resource-server/src/mcp-app.css +++ b/examples/video-resource-server/src/mcp-app.css @@ -8,16 +8,16 @@ flex-direction: column; align-items: center; justify-content: center; - padding: 2rem; - gap: 1rem; - color: #6b7280; + padding: var(--spacing-lg); + gap: var(--spacing-md); + color: var(--color-text-info); } .spinner { width: 48px; height: 48px; - border: 4px solid #e5e7eb; - border-top-color: #3b82f6; + border: 4px solid color-mix(in srgb, var(--color-text-primary) 20%, transparent); + border-top-color: var(--color-accent); border-radius: 50%; animation: spin 1s linear infinite; } @@ -27,13 +27,13 @@ } .error { - padding: 1rem; + padding: var(--spacing-md); color: #dc2626; } .error-title { - font-weight: 600; - margin: 0 0 0.5rem 0; + font-weight: var(--font-weight-bold); + margin: 0 0 var(--spacing-sm) 0; } .error p { @@ -43,12 +43,75 @@ .player video { width: 100%; max-height: 480px; - border-radius: 8px; + border-radius: var(--border-radius-md); background-color: #000; } .video-info { - margin-top: 0.5rem; + margin-top: var(--spacing-sm); font-size: 0.875rem; - color: #6b7280; + color: var(--color-text-info); +} + +.picker-container { + padding: var(--spacing-md); +} + +.picker-container h3 { + margin: 0 0 var(--spacing-md) 0; + font-size: var(--font-heading-lg-size); + font-weight: var(--font-weight-bold); +} + +.video-picker { + width: 100%; + padding: var(--spacing-sm); + font-size: var(--font-text-md-size); + border: var(--border-width-regular) solid color-mix(in srgb, var(--color-text-primary) 20%, transparent); + border-radius: var(--border-radius-md); + margin-bottom: var(--spacing-md); + background-color: var(--color-background-primary); + color: var(--color-text-primary); + font-family: var(--font-sans); +} + +@media (prefers-color-scheme: dark) { + .video-picker option { + background-color: var(--color-background-info); + color: var(--color-text-primary); + } +} + +.load-btn, +.change-btn { + padding: var(--spacing-sm) var(--spacing-md); + font-size: var(--font-text-md-size); + font-weight: var(--font-weight-bold); + color: var(--color-text-on-accent); + background-color: var(--color-accent); + border: none; + border-radius: var(--border-radius-md); + cursor: pointer; + transition: background-color 0.2s; +} + +.load-btn:hover:not(:disabled), +.change-btn:hover { + background-color: color-mix(in srgb, var(--color-accent) 85%, var(--color-background-inverse)); +} + +.load-btn:focus-visible, +.change-btn:focus-visible { + outline: calc(var(--border-width-regular) * 2) solid var(--color-ring-primary); + outline-offset: var(--border-width-regular); +} + +.load-btn:disabled { + background-color: color-mix(in srgb, var(--color-text-primary) 40%, transparent); + cursor: not-allowed; + opacity: 0.6; +} + +.change-btn { + margin-top: var(--spacing-md); } diff --git a/examples/video-resource-server/src/mcp-app.ts b/examples/video-resource-server/src/mcp-app.ts index bc7688a81..9888df01e 100644 --- a/examples/video-resource-server/src/mcp-app.ts +++ b/examples/video-resource-server/src/mcp-app.ts @@ -6,7 +6,6 @@ */ import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { ReadResourceResultSchema } from "@modelcontextprotocol/sdk/types.js"; import "./global.css"; import "./mcp-app.css"; @@ -19,9 +18,19 @@ const loadingEl = document.getElementById("loading")!; const loadingTextEl = document.getElementById("loading-text")!; const errorEl = document.getElementById("error")!; const errorMessageEl = document.getElementById("error-message")!; +const pickerContainerEl = document.getElementById("video-picker-container")!; +const videoPickerEl = document.getElementById( + "video-picker", +) as HTMLSelectElement; +const loadVideoBtn = document.getElementById( + "load-video-btn", +) as HTMLButtonElement; const playerEl = document.getElementById("player")!; const videoEl = document.getElementById("video") as HTMLVideoElement; const videoInfoEl = document.getElementById("video-info")!; +const changeVideoBtn = document.getElementById( + "change-video-btn", +) as HTMLButtonElement; // ============================================================================= // UI State Helpers @@ -41,6 +50,7 @@ function showLoading(text: string) { loadingTextEl.textContent = text; loadingEl.style.display = "flex"; errorEl.style.display = "none"; + pickerContainerEl.style.display = "none"; playerEl.style.display = "none"; } @@ -48,15 +58,36 @@ function showError(message: string) { errorMessageEl.textContent = message; loadingEl.style.display = "none"; errorEl.style.display = "block"; + pickerContainerEl.style.display = "none"; playerEl.style.display = "none"; } -function showPlayer(dataUri: string, info: string) { - videoEl.src = dataUri; +function showPicker(keepPlayer = true) { + loadingEl.style.display = "none"; + errorEl.style.display = "none"; + pickerContainerEl.style.display = "block"; + changeVideoBtn.style.display = "none"; + if (!keepPlayer || !videoEl.src || videoEl.src === window.location.href) { + playerEl.style.display = "none"; + } +} + +let currentObjectUrl: string | null = null; + +function showPlayer(objectUrl: string, info: string) { + // Revoke previous Object URL to free memory + if (currentObjectUrl) { + URL.revokeObjectURL(currentObjectUrl); + } + currentObjectUrl = objectUrl; + + videoEl.src = objectUrl; videoInfoEl.textContent = info; loadingEl.style.display = "none"; errorEl.style.display = "none"; + pickerContainerEl.style.display = "none"; playerEl.style.display = "block"; + changeVideoBtn.style.display = "inline-block"; } // ============================================================================= @@ -65,8 +96,81 @@ function showPlayer(dataUri: string, info: string) { const app = new App({ name: "Video Resource Player", version: "1.0.0" }); -// Handle tool result - Requests a resource via resources/read and converts the -// base64 blob to a data URI for use in the browser. +async function fetchAndPlayVideo(uri: string, label: string) { + console.info("Requesting resource:", uri); + + const resourceResult = await app.readServerResource({ uri }); + + const content = resourceResult.contents[0]; + if (!content || !("blob" in content)) { + throw new Error("Resource response did not contain blob data"); + } + + console.info("Resource received, blob size:", content.blob.length); + + const mimeType = content.mimeType || "video/mp4"; + const binary = Uint8Array.from(atob(content.blob), (c) => c.charCodeAt(0)); + const objectUrl = URL.createObjectURL(new Blob([binary], { type: mimeType })); + + showPlayer(objectUrl, label); +} + +async function discoverVideos() { + // Don't interrupt playback with loading state - just show picker if video is loaded + const hasVideo = Boolean(videoEl.src && videoEl.src !== window.location.href); + if (!hasVideo) { + showLoading("Discovering available videos..."); + } + + try { + const resourceList = await app.listServerResources({}); + + const videoResources = resourceList.resources.filter((r) => + r.uri.startsWith("videos://"), + ); + + if (videoResources.length === 0) { + showError("No videos available. Server has no video resources."); + return; + } + + videoPickerEl.innerHTML = ''; + + videoResources.forEach((resource) => { + const option = document.createElement("option"); + option.value = resource.uri; + option.text = resource.name || resource.description || resource.uri; + videoPickerEl.appendChild(option); + }); + + showPicker(hasVideo); + } catch (err) { + console.error("Error discovering videos:", err); + showError(err instanceof Error ? err.message : String(err)); + } +} + +async function loadSelectedVideo() { + const selectedUri = videoPickerEl.value; + if (!selectedUri) { + return; + } + + showLoading("Loading video..."); + + try { + const selectedText = + videoPickerEl.options[videoPickerEl.selectedIndex].text; + await fetchAndPlayVideo( + selectedUri, + `${selectedText} (loaded via MCP resource)`, + ); + } catch (err) { + console.error("Error loading video:", err); + showError(err instanceof Error ? err.message : String(err)); + } +} + app.ontoolresult = async (result) => { console.info("Received tool result:", result); @@ -82,28 +186,10 @@ app.ontoolresult = async (result) => { showLoading("Fetching video from MCP resource..."); try { - console.info("Requesting resource:", videoUri); - - const resourceResult = await app.request( - { method: "resources/read", params: { uri: videoUri } }, - ReadResourceResultSchema, + await fetchAndPlayVideo( + videoUri, + `Loaded via MCP resource (${description})`, ); - - const content = resourceResult.contents[0]; - if (!content || !("blob" in content)) { - throw new Error("Resource response did not contain blob data"); - } - - console.info("Resource received, blob size:", content.blob.length); - - showLoading("Converting to data URI..."); - - const mimeType = content.mimeType || "video/mp4"; - const dataUri = `data:${mimeType};base64,${content.blob}`; - - console.info("Data URI created, length:", dataUri.length); - - showPlayer(dataUri, `Loaded via MCP resource (${description})`); } catch (err) { console.error("Error fetching resource:", err); showError(err instanceof Error ? err.message : String(err)); @@ -115,6 +201,16 @@ app.onerror = (err) => { showError(err instanceof Error ? err.message : String(err)); }; +videoPickerEl.addEventListener("change", () => { + loadVideoBtn.disabled = !videoPickerEl.value; +}); + +loadVideoBtn.addEventListener("click", loadSelectedVideo); + +changeVideoBtn.addEventListener("click", () => { + discoverVideos(); +}); + function handleHostContextChanged(ctx: McpUiHostContext) { if (ctx.safeAreaInsets) { mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`; diff --git a/src/app.examples.ts b/src/app.examples.ts index 4705071b5..f7fc9e3ec 100644 --- a/src/app.examples.ts +++ b/src/app.examples.ts @@ -297,6 +297,60 @@ async function App_callServerTool_fetchWeather(app: App) { //#endregion App_callServerTool_fetchWeather } +/** + * Example: Read a video resource and play it. + */ +async function App_readServerResource_playVideo( + app: App, + videoElement: HTMLVideoElement, +) { + //#region App_readServerResource_playVideo + try { + const result = await app.readServerResource({ + uri: "videos://bunny-1mb", + }); + const content = result.contents[0]; + if (content && "blob" in content) { + const binary = Uint8Array.from(atob(content.blob), (c) => + c.charCodeAt(0), + ); + const url = URL.createObjectURL( + new Blob([binary], { type: content.mimeType || "video/mp4" }), + ); + videoElement.src = url; + videoElement.play(); + } + } catch (error) { + console.error("Failed to read resource:", error); + } + //#endregion App_readServerResource_playVideo +} + +/** + * Example: Discover available videos and build a picker UI. + */ +async function App_listServerResources_buildPicker( + app: App, + selectElement: HTMLSelectElement, +) { + //#region App_listServerResources_buildPicker + try { + const result = await app.listServerResources({}); + const videoResources = result.resources.filter((r) => + r.mimeType?.startsWith("video/"), + ); + videoResources.forEach((resource) => { + const option = document.createElement("option"); + option.value = resource.uri; + option.textContent = resource.description || resource.name; + selectElement.appendChild(option); + }); + } catch (error) { + console.error("Failed to list resources:", error); + } + //#endregion App_listServerResources_buildPicker +} + /** * Example: Send a text message from user interaction. */ diff --git a/src/app.ts b/src/app.ts index f813f44c8..e7c1ef859 100644 --- a/src/app.ts +++ b/src/app.ts @@ -11,10 +11,16 @@ import { CallToolResultSchema, EmptyResultSchema, Implementation, + ListResourcesRequest, + ListResourcesResult, + ListResourcesResultSchema, ListToolsRequest, ListToolsRequestSchema, LoggingMessageNotification, PingRequestSchema, + ReadResourceRequest, + ReadResourceResult, + ReadResourceResultSchema, } from "@modelcontextprotocol/sdk/types.js"; import { AppNotification, AppRequest, AppResult } from "./types"; import { PostMessageTransport } from "./message-transport"; @@ -731,6 +737,104 @@ export class App extends Protocol