Skip to main content
All posts

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

Abe Wheeler
MCP AppsMCP App FrameworkMCP App TestingChatGPT AppsChatGPT App FrameworkChatGPT App TestingClaude ConnectorsClaude Connector FrameworkMCP ResourceslistServerResourcesreadServerResource
An MCP App asks its host to list and read resources from the originating MCP server.

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.

MCP uses the word “resource” for several related things, which can make this API harder to reason about.

KindWhere it appearsWhat it does
MCP App UI resourceTool metadata points to its ui:// URISupplies the HTML document that the host mounts
Listed server resourceReturned by resources/listDescribes content the app or client may read later
Resource linkReturned in a tool result content blockPoints to one resource relevant to that tool call
Embedded resourceReturned in a tool result content blockIncludes 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.

StateWhat the user should seeRecovery
Host lacks serverResourcesResource browsing is unavailable hereUse tool-result data or an intentional server-tool fallback
Empty first pageNo resources are availableRefresh if the collection can change
List request failsThe resource list could not loadRetry from the first page
Cursor expiresThe list changed while loadingClear pages and restart
Read returns no contentThe resource is emptyReturn to the picker
Read is denied or missingThe resource cannot be openedRefresh the list and choose another item
MIME type is unsupportedPreview is unavailableOffer a safe download if the host and product allow it
New selection wins a raceKeep the latest selection visibleIgnore 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:

  1. The server authorizes every resources/read request against the current user and session.
  2. The app accepts only URI schemes or prefixes that belong in the picker.
  3. The app validates content type and size before decoding or rendering.
  4. Text renders as text by default. HTML and SVG need a reviewed sanitizer or a non-executable preview.
  5. Binary previews use an allowlist and release object URLs when finished.
  6. Errors avoid leaking server paths, tokens, or private resource names.
  7. 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:

  • serverResources missing, present, and present with listChanged
  • 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:

  1. Connect the app and read hostCapabilities.serverResources.
  2. List one page and render names with stable URI keys.
  3. Read one selected URI and render plain text safely.
  4. Add loading, empty, retry, and unsupported-host states.
  5. Add cursor pagination and selection race protection.
  6. Add approved binary preview types and object URL cleanup.
  7. Add list-change invalidation or a deliberate refresh policy.
  8. 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

Documentation →
npx sunpeak new

Further Reading

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.