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
8 changed files with 502 additions and 40 deletions

View file

@ -21,6 +21,15 @@
<div id="player" class="player" style="display: none;">
<video id="video" controls></video>
<p id="video-info" class="video-info"></p>
<button id="change-video-btn" class="change-btn">Change Video</button>
</div>
<div id="video-picker-container" class="picker-container" style="display: none;">
<h3>Available Videos</h3>
<select id="video-picker" class="video-picker">
<option value="">Select a video...</option>
</select>
<button id="load-video-btn" class="load-btn" disabled>Load Video</button>
</div>
</main>
<script type="module" src="/src/mcp-app.ts"></script>

View file

@ -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",

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`;

View file

@ -779,6 +779,27 @@ describe("App <-> AppBridge integration", () => {
expect(result.resources).toEqual(resources);
});
it("onlistresources handles listServerResources() calls from App", async () => {
const resources = [
{ uri: "test://res-1", name: "Resource 1" },
{ uri: "test://res-2", name: "Resource 2", mimeType: "video/mp4" },
];
const receivedRequests: unknown[] = [];
bridge.onlistresources = async (params) => {
receivedRequests.push(params);
return { resources };
};
await bridge.connect(bridgeTransport);
await app.connect(appTransport);
const result = await app.listServerResources({});
expect(receivedRequests).toHaveLength(1);
expect(result.resources).toEqual(resources);
});
it("onreadresource setter registers handler for resources/read requests", async () => {
const requestParams = { uri: "test://resource" };
const contents = [{ uri: "test://resource", text: "content" }];
@ -802,6 +823,28 @@ describe("App <-> AppBridge integration", () => {
expect(result.contents).toEqual(contents);
});
it("onreadresource handles readServerResource() calls from App", async () => {
const requestParams = { uri: "videos://bunny-1mb" };
const contents = [
{ uri: "videos://bunny-1mb", blob: "dmlkZW9kYXRh", mimeType: "video/mp4" },
];
const receivedRequests: unknown[] = [];
bridge.onreadresource = async (params) => {
receivedRequests.push(params);
return { contents };
};
await bridge.connect(bridgeTransport);
await app.connect(appTransport);
const result = await app.readServerResource(requestParams);
expect(receivedRequests).toHaveLength(1);
expect(receivedRequests[0]).toMatchObject(requestParams);
expect(result.contents).toEqual(contents);
});
it("onlistresourcetemplates setter registers handler for resources/templates/list requests", async () => {
const requestParams = {};
const resourceTemplates = [

View file

@ -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.
*/

View file

@ -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";
@ -739,6 +745,104 @@ export class App extends Protocol<AppRequest, AppNotification, AppResult> {
);
}
/**
* Read a resource from the originating MCP server (proxied through the host).
*
* Apps can read resources to access files, data, or other content provided by
* the MCP server. Resources are identified by URI (e.g., `file:///path/to/file`
* or custom schemes like `videos://bunny-1mb`). The host proxies the request to
* the actual MCP server and returns the resource content.
*
* @param params - Resource URI to read
* @param options - Request options (timeout, etc.)
* @returns Resource content with URI, name, description, mimeType, and contents array
*
* @throws {Error} If the resource does not exist on the server
* @throws {Error} If the request times out or the connection is lost
* @throws {Error} If the host rejects the request
*
* @example Read a video resource and play it
* ```ts source="./app.examples.ts#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);
* }
* ```
*
* @see {@link listServerResources `listServerResources`} to discover available resources
*/
async readServerResource(
params: ReadResourceRequest["params"],
options?: RequestOptions,
): Promise<ReadResourceResult> {
return await this.request(
{ method: "resources/read", params },
ReadResourceResultSchema,
options,
);
}
/**
* List available resources from the originating MCP server (proxied through the host).
*
* Apps can list resources to discover what content is available on the MCP server.
* This enables dynamic resource discovery and building resource browsers or pickers.
* The host proxies the request to the actual MCP server and returns the resource list.
*
* Results may be paginated using the `cursor` parameter for servers with many resources.
*
* @param params - Optional parameters (empty object `{}` for all resources, or `{ cursor }` for pagination)
* @param options - Request options (timeout, etc.)
* @returns List of resources with their URIs, names, descriptions, mimeTypes, and optional pagination cursor
*
* @throws {Error} If the request times out or the connection is lost
* @throws {Error} If the host rejects the request
*
* @example Discover available videos and build a picker UI
* ```ts source="./app.examples.ts#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);
* }
* ```
*
* @see {@link readServerResource `readServerResource`} to read a specific resource
*/
async listServerResources(
params: ListResourcesRequest["params"],
options?: RequestOptions,
): Promise<ListResourcesResult> {
return await this.request(
{ method: "resources/list", params },
ListResourcesResultSchema,
options,
);
}
/**
* Send a message to the host's chat interface.
*