feat: add readServerResource() and listServerResources() api to App (#470)

* feat: add readServerResource() and listServerResources() api to App class

* test: add listServerResources and readServerResource integration tests
This commit is contained in:
Hryhorii (Greg) Avcharov 2026-03-06 15:20:45 +01:00 • committed by GitHub
commit 4f9a7ebc74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 502 additions and 40 deletions

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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 = '<option value="">Select a video...</option>';
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`;