MCP App Server Resources: List, Read, Paginate, and Render

An MCP App asks its host to list and read resources from the originating MCP server.
An MCP App often needs data that does not belong in its initial tool result. A file picker may need to discover files, a report viewer may load one document at a time, and a media browser may fetch a thumbnail only when it enters the viewport.
The MCP Apps SDK has a direct path for this work. listServerResources() discovers resources from the originating MCP server, and readServerResource() reads one by URI. The host proxies both requests, so the app does not need direct network access or server credentials.
TL;DR: Wait for app.connect(), check app.getHostCapabilities()?.serverResources, list every page with listServerResources(), and read the selected URI with readServerResource({ uri }). Treat returned text and blobs as untrusted input. Keep the UI usable when the host cannot proxy resources, and test empty, paginated, stale, binary, and error states.
Why Server Resources Are the Largest Open Search Gap
Most MCP App resource guides focus on the HTML resource that mounts the UI. Search results explain ui:// URIs, text/html;profile=mcp-app, tool metadata, and Content Security Policy. Those are all part of building an app, but they stop before the running UI needs to browse data.
The current MCP Apps App API documents listServerResources and readServerResource. The MCP resources specification documents resources/list, resources/read, cursors, and list-change notifications. Few guides join those pieces into a complete app-side flow.
That leaves developers searching for the same implementation details:
- How do I build a resource picker inside an MCP App?
- Which capability do I check before listing resources?
- How do I load every cursor page?
- What is the difference between the app’s HTML resource and data resources?
- How do I safely render text and base64 blobs?
- What should happen when a host does not support resource proxying?
- How do I test changes to a resource collection?
This guide answers those questions with the current SDK API.
How the Request Path Works
An app does not call its MCP server over HTTP from inside the iframe. It sends a standard MCP request across the app bridge, then the host forwards that request to the server connection it already owns.
MCP App iframe
|
| resources/list or resources/read
v
Host bridge
|
| proxied MCP request
v
Originating MCP server
This path has two useful properties. First, the host keeps server transport and authorization outside the iframe. Second, reading a resource does not require adding its server origin to the app’s CSP connectDomains, because the app is using the bridge instead of fetch().
The app still needs host support. After connection, app.getHostCapabilities()?.serverResources tells you whether the host can proxy resource requests. Its optional listChanged field says the host supports resources/list_changed notifications.
UI Resources, Listed Resources, and Resource Links
MCP uses the word “resource” for several related things, which can make this API harder to reason about.
| Kind | Where it appears | What it does |
|---|---|---|
| MCP App UI resource | Tool metadata points to its ui:// URI | Supplies the HTML document that the host mounts |
| Listed server resource | Returned by resources/list | Describes content the app or client may read later |
| Resource link | Returned in a tool result content block | Points to one resource relevant to that tool call |
| Embedded resource | Returned in a tool result content block | Includes the resource content in the result itself |
These sets can overlap, but they are not interchangeable. A server may expose its ui:// templates in resources/list, so a data picker should filter by a known URI prefix or MIME type. A resource link in a tool result is a direct pointer, not a promise that the same URI appears in the current resource list. An embedded resource already contains data, so reading it again may waste a request.
Use listServerResources() when the user needs to discover a collection. Use readServerResource() when you have a URI and need its current content.
Check the Host Capability After Connection
Host capabilities arrive during the initialize handshake, so read them only after app.connect() resolves.
import { App } from '@modelcontextprotocol/ext-apps';
const app = new App(
{ name: 'report-browser', version: '1.0.0' },
{},
{ autoResize: true },
);
await app.connect();
const resourceSupport = app.getHostCapabilities()?.serverResources;
if (!resourceSupport) {
showResourceFallback();
} else {
showResourceBrowser();
}
Do not hide the whole app when the capability is absent. Disable only the part that depends on server resources, explain why it is unavailable, and keep any tool-result data usable.
If the same server exposes a narrow lookup tool, callServerTool() can be a fallback when serverTools exists but serverResources does not. That changes the contract, though. The tool needs its own input schema, output schema, authorization checks, and pagination behavior. Treat it as a designed fallback, not an automatic substitute.
List Every Resource Page
The first call takes no parameters. A server may return nextCursor, which you pass to the next call. Stop when the cursor is absent.
import type { App } from '@modelcontextprotocol/ext-apps';
type Resource = {
uri: string;
name: string;
title?: string;
description?: string;
mimeType?: string;
size?: number;
};
export async function listAllResources(app: App): Promise<Resource[]> {
const byUri = new Map<string, Resource>();
let cursor: string | undefined;
do {
const page = await app.listServerResources(
cursor ? { cursor } : undefined,
);
for (const resource of page.resources) {
byUri.set(resource.uri, resource);
}
cursor = page.nextCursor;
} while (cursor);
return [...byUri.values()];
}
Deduplication by URI makes the picker more stable when the collection changes while you load later pages. It does not turn a cursor into a snapshot. The server owns cursor semantics, so the app should handle an expired or invalid cursor by clearing the partial list and starting again.
Large catalogs should not block on every page before rendering. Load the first page, show it, then offer “Load more” or fetch later pages in the background. Keep the current cursor with the current filter or collection version so a refresh cannot accidentally continue an old traversal.
The resource descriptor is metadata, not the resource body. mimeType and size may be absent, so use them as hints. Do not assume a file is safe or small because its list entry says so.
Read the Selected Resource
Once the user selects a URI, pass it to readServerResource().
async function loadReport(app: App, uri: string) {
setStatus('loading');
try {
const result = await app.readServerResource({ uri });
if (result.contents.length === 0) {
setStatus('empty');
return;
}
renderResourceContents(result.contents);
setStatus('ready');
} catch (error) {
setStatus('error');
showRetry(error instanceof Error ? error.message : 'Read failed');
}
}
A read result can contain more than one content item. Process the array instead of taking contents[0] without checking. Each item has its own URI and optional MIME type, and it contains either text or a base64-encoded blob.
Also protect the UI from response races. If the user selects resource B while resource A is still loading, an older response must not replace the newer selection. Track a request number or abort the old request when your request options and host support cancellation.
let latestRead = 0;
async function selectResource(app: App, uri: string) {
const requestId = ++latestRead;
setStatus('loading');
try {
const result = await app.readServerResource({ uri });
if (requestId !== latestRead) return;
renderResourceContents(result.contents);
setStatus('ready');
} catch (error) {
if (requestId !== latestRead) return;
setStatus('error');
}
}
Render Text Without Trusting It
Resource text can be plain text, Markdown, JSON, CSV, SVG, or HTML. The presence of a MIME type does not make the content trusted.
For plain text, use textContent in DOM code or normal text interpolation in React. For JSON, parse inside a try block and validate the expected shape before using it. For Markdown, configure your renderer to reject raw HTML unless you have a reviewed sanitizer.
function renderText(container: HTMLElement, text: string) {
container.replaceChildren();
const pre = document.createElement('pre');
pre.textContent = text;
container.append(pre);
}
Do not assign resource text to innerHTML. A resource can contain scriptable markup even when its URI and MIME type look familiar.
Add application limits before rendering. A picker can reject an unexpected URI scheme, warn before loading a resource whose reported size is large, and stop rendering text beyond a chosen limit. The server must still enforce authorization for every read, because anything listed or linked in the app can be copied into a later request.
Decode and Clean Up Binary Content
Blob content arrives as base64. Decode it into bytes, create a browser Blob, and then create an object URL.
function contentToObjectUrl(content: {
blob: string;
mimeType?: string;
}) {
const binary = atob(content.blob);
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
const blob = new Blob([bytes], {
type: content.mimeType ?? 'application/octet-stream',
});
return URL.createObjectURL(blob);
}
Object URLs hold browser memory until you revoke them. Revoke the previous URL when the selection changes and revoke the final URL during teardown.
let currentObjectUrl: string | undefined;
function replaceMediaSource(element: HTMLMediaElement, nextUrl: string) {
if (currentObjectUrl) URL.revokeObjectURL(currentObjectUrl);
currentObjectUrl = nextUrl;
element.src = nextUrl;
}
app.onteardown = async () => {
if (currentObjectUrl) URL.revokeObjectURL(currentObjectUrl);
return {};
};
Choose renderers from an allowlist. For example, accept image/png, image/jpeg, and image/webp in an image preview, while treating other types as downloads or unsupported content. Do not place a blob into an HTML-capable element only because the server labeled it text/html or image/svg+xml.
A React Resource Picker with sunpeak
sunpeak wraps the same bridge calls in useListServerResources() and useReadServerResource(). Each hook returns a callback, which keeps the request flow explicit.
import {
useListServerResources,
useReadServerResource,
} from 'sunpeak';
import { useEffect, useState } from 'react';
type Resource = {
uri: string;
name: string;
description?: string;
mimeType?: string;
};
export function ResourcePicker() {
const listResources = useListServerResources();
const readResource = useReadServerResource();
const [resources, setResources] = useState<Resource[]>([]);
const [text, setText] = useState('');
const [error, setError] = useState('');
useEffect(() => {
let active = true;
listResources()
.then((page) => {
if (active && page) setResources(page.resources);
})
.catch(() => {
if (active) setError('Could not load resources.');
});
return () => {
active = false;
};
}, [listResources]);
async function openResource(uri: string) {
setError('');
try {
const result = await readResource({ uri });
const item = result?.contents.find((content) => content.text != null);
setText(item?.text ?? 'This resource has no text preview.');
} catch {
setError('Could not read this resource.');
}
}
return (
<section>
<label htmlFor="resource">Resource</label>
<select
id="resource"
defaultValue=""
onChange={(event) => openResource(event.target.value)}
>
<option value="" disabled>
Choose a resource
</option>
{resources.map((resource) => (
<option key={resource.uri} value={resource.uri}>
{resource.name}
</option>
))}
</select>
{error ? <p role="alert">{error}</p> : null}
{text ? <pre>{text}</pre> : null}
</section>
);
}
This sample keeps the first page short so the list/read shape is easy to see. A production picker should add cursor handling, a loading indicator, selection race protection, an empty state, and the host capability fallback described above.
Handle Resource List Changes
The core MCP resource capability can announce notifications/resources/list_changed when the list changes. On the app side, the host’s serverResources.listChanged capability tells you that the host supports those notifications.
Treat a list-change signal as an invalidation, not as the new list. Clear stale cursors and request the first page again. Preserve the selected URI only if it still exists after refresh. If the current item disappears, show a clear removed or unavailable state rather than leaving old content on screen.
Do not poll only because listChanged is absent. Some resource collections are stable for the lifetime of the app. For changing collections, offer a manual refresh button or use a low-frequency refresh policy that matches the data and host limits.
The public App event surface can change as this part of MCP Apps matures, so bind list-change handling through the event API supported by the SDK version you ship. Capability detection alone does not install a handler.
Error States Worth Designing
Resource errors are normal UI states, not console-only failures.
| State | What the user should see | Recovery |
|---|---|---|
Host lacks serverResources | Resource browsing is unavailable here | Use tool-result data or an intentional server-tool fallback |
| Empty first page | No resources are available | Refresh if the collection can change |
| List request fails | The resource list could not load | Retry from the first page |
| Cursor expires | The list changed while loading | Clear pages and restart |
| Read returns no content | The resource is empty | Return to the picker |
| Read is denied or missing | The resource cannot be opened | Refresh the list and choose another item |
| MIME type is unsupported | Preview is unavailable | Offer a safe download if the host and product allow it |
| New selection wins a race | Keep the latest selection visible | Ignore or cancel the old response |
Avoid putting raw protocol errors into the page. Log enough detail for development, then show the user a short message and a useful next action.
Security Rules for Resource Browsers
The host proxy removes the need to put server credentials in the iframe, but it does not make the returned content safe. Apply the same controls you would use for data from an API:
- The server authorizes every
resources/readrequest against the current user and session. - The app accepts only URI schemes or prefixes that belong in the picker.
- The app validates content type and size before decoding or rendering.
- Text renders as text by default. HTML and SVG need a reviewed sanitizer or a non-executable preview.
- Binary previews use an allowlist and release object URLs when finished.
- Errors avoid leaking server paths, tokens, or private resource names.
- Logs record the resource category or a safe identifier instead of sensitive content.
The list is not an authorization boundary. A user can alter a URI in browser state, so the server must reject reads that the current session cannot access.
Test the Full List and Read Flow
Start with bridge stubs in component tests. Return controlled pages and content so tests stay fast and do not depend on a live server.
Cover at least these cases:
serverResourcesmissing, present, and present withlistChanged- an empty list, one page, several pages, and repeated URIs
- a missing, stale, or rejected cursor
- text, JSON, multiple content items, and base64 blob results
- missing or misleading MIME types
- invalid base64 and content larger than the app limit
- a read that fails, times out, or finishes after a newer selection
- a list refresh where the selected resource disappears
- object URL cleanup on replacement and teardown
Then run the built app in a real browser through an MCP App host or MCP App inspector. Verify the capability state, first load, pagination, selection, refresh, retry, and teardown. Inspect console errors, failed requests, and server logs, because a rendered picker can look correct while the bridge is retrying or the server is returning the wrong MIME type.
A Practical Build Order
Build the smallest useful path first:
- Connect the app and read
hostCapabilities.serverResources. - List one page and render names with stable URI keys.
- Read one selected URI and render plain text safely.
- Add loading, empty, retry, and unsupported-host states.
- Add cursor pagination and selection race protection.
- Add approved binary preview types and object URL cleanup.
- Add list-change invalidation or a deliberate refresh policy.
- Test the bridge stubs, then verify the whole path in a browser host.
That order gives you a useful resource browser before binary rendering and live collection changes add more state. It also keeps each failure easy to reproduce.
MCP server resources are a good fit when a collection is discoverable, addressable by URI, and useful beyond one tool call. With the host proxy, a running MCP App can browse that collection without adding credentials or a second transport to the iframe. The remaining work is normal product engineering: paginate carefully, render defensively, explain failure states, and test the hosts you support.
Get Started
npx sunpeak newFurther Reading
- MCP App UI resources, URI, MIME type, and resource links
- MCP App resource templates
- MCP App resource caching and versioned UI
- MCP App capability detection, fallbacks, and tests
- Fetching data in MCP Apps
- MCP App framework
- MCP App inspector
- sunpeak listServerResources reference
- sunpeak readServerResource reference
- MCP Apps App class API
- MCP resources specification
Frequently Asked Questions
How does an MCP App read a server resource?
After the App connection is ready, check app.getHostCapabilities()?.serverResources, then call app.readServerResource({ uri }). The host proxies the resources/read request to the originating MCP server. The result contains one or more content items with text or a base64-encoded blob, plus the URI and optional MIME type.
How does listServerResources pagination work?
Call app.listServerResources() for the first page. If the result has nextCursor, pass it back as app.listServerResources({ cursor: nextCursor }). Keep loading until nextCursor is absent. Preserve the order returned by the server and deduplicate by URI if a changing collection can overlap between pages.
What does hostCapabilities.serverResources mean?
It means the MCP App host can proxy resource requests from the app to its originating MCP server. The optional serverResources.listChanged flag says the host supports resources/list_changed notifications. Check the capability after app.connect() and keep a fallback for hosts that do not advertise it.
What is the difference between an MCP App UI resource and a server data resource?
The UI resource is the HTML document, usually identified by a ui:// URI, that the host loads to mount the app. Server data resources are content the running app discovers or reads through resources/list and resources/read. Both use MCP resources, so a resource list may contain UI templates unless the server separates or filters them.
Can readServerResource return binary data?
Yes. A resource content item can contain a base64-encoded blob and an optional MIME type. Decode the base64 into bytes, create a Blob, and use an object URL for an image, audio file, video, or download. Revoke old object URLs when the component changes or unmounts.
Should an MCP App render resource text as HTML?
No. Treat resource content as untrusted input. Render plain text with textContent or normal framework text interpolation, validate JSON before using it, and only pass HTML through a sanitizer that matches your product policy. Also limit accepted URI prefixes, MIME types, and response sizes.
How should I test MCP App server resources?
Test an unsupported host, empty and multi-page lists, duplicate URIs, stale cursors, read errors, multiple content items, malformed JSON, missing MIME types, invalid base64, large blobs, retries, and rapid selection changes. Then run the app in a real MCP App host or inspector and inspect the resource list, loading states, rendered content, console, and request failures.