MCP Apps
    Preparing search index...
    • Getting Started

    MCP Apps Patterns

    This document covers common patterns and recipes for building MCP Apps.

    Set Tool._meta.ui.visibility to ["app"] to make tools only callable by Apps (hidden from the model). This is useful for UI-driven actions like updating server-side state, polling, or other interactions that shouldn't appear in the model's tool list.

    registerAppTool(
    server,
    "update-quantity",
    {
    description: "Update item quantity in cart",
    inputSchema: z.object({ itemId: z.string(), quantity: z.number() }),
    _meta: {
    ui: {
    resourceUri: "ui://shop/cart.html",
    visibility: ["app"],
    },
    },
    },
    async ({ itemId, quantity }) => {
    const cart = await updateCartItem(itemId, quantity);
    return { content: [{ type: "text", text: JSON.stringify(cart) }] };
    },
    );
    Note

    For full examples that implement this pattern, see: examples/system-monitor-server/ and examples/pdf-server/.

    Note

    entrypoints is part of the draft specification. Hosts that don't support it ignore it, and the tool keeps working as an ordinary tool.

    By default a View only appears when the agent calls its tool. Add an entry to Tool._meta.ui.entrypoints to tell the host that the user can open the View directly, so that the host can offer it in its own UI. A "global" entry says that the View is a useful starting point by itself (in a list of apps, for instance). When the user picks it, the host calls the tool with {} as arguments, or asks the agent to, and renders the View as for any other tool call.

    registerAppTool(
    server,
    "show-cart",
    {
    title: "Shopping Cart",
    description: "Display the user's shopping cart",
    // Opened with `{}`: no required arguments, and nothing changes
    annotations: { readOnlyHint: true },
    _meta: {
    ui: {
    resourceUri: "ui://shop/cart.html",
    entrypoints: [{ type: "global" }],
    },
    },
    },
    async () => {
    const cart = await getCart();
    return { content: [{ type: "text", text: JSON.stringify(cart) }] };
    },
    );

    Such a tool has to work with no arguments: give it no required inputs, return a result that the View can render from, and don't change anything when called with {} (opening a View is not a request to act). The tool's title (or name) and icons are what the host shows, so choose them for people as well as for the model. The tool remains an ordinary tool that the agent can call too, and its result may reach the model.

    There are three types of entry, and a tool can declare several:

    Entry The user opens the View... The host calls the tool with
    { type: "global" } as a starting point, e.g. from a list of apps {}
    { type: "thread" } inside a conversation, to which it belongs {}
    { type: "file", extensions, mimeTypes } by opening a file of one of these kinds the file, as its one argument

    Use "thread" for a View that makes sense beside a conversation that already exists: its ui/message and ui/update-model-context requests reach that conversation, and each conversation gets its own View.

    Use "file" for a View that shows or plays files. The file reaches the tool as a file argument of the File Uploads extension (io.modelcontextprotocol/files), which is a working draft: the tool has exactly one top-level property marked with x-mcp-file, required, and no other required property. The host may leave the file's bytes out of the tool input that it sends to the View, so return what the View needs in the tool result.

    registerAppTool(
    server,
    "open-spreadsheet",
    {
    title: "Spreadsheet",
    description: "Show a spreadsheet file",
    inputSchema: z.object({
    // The one file argument, marked for the File Uploads extension.
    // The host binds it to the file that the user opened.
    file: z
    .object({
    id: z.string(),
    name: z.string().optional(),
    mimeType: z.string(),
    size: z.number(),
    digest: z.object({
    algorithm: z.literal("sha-256"),
    value: z.string(),
    }),
    data: z.string().optional(),
    })
    .meta({ "x-mcp-file": { accept: [".csv", "text/csv"] } }),
    }),
    annotations: { readOnlyHint: true },
    _meta: {
    ui: {
    resourceUri: "ui://sheets/viewer.html",
    entrypoints: [
    { type: "file", extensions: [".csv"], mimeTypes: ["text/csv"] },
    ],
    },
    },
    },
    async ({ file }) => {
    // Checks the size and digest, then decodes the bytes
    const rows = parseCsv(file);
    // The view renders from the result: its tool input may lack the bytes
    return {
    content: [{ type: "text", text: `Opened ${file.name}` }],
    structuredContent: { rows },
    };
    },
    );

    Hosts can read the field with getToolEntrypoints, which skips malformed entries, match a file against an entry with fileMatchesEntrypoint, and find the tool's file argument with getToolFileArgument.

    Note

    For full examples that implement this pattern, see: examples/system-monitor-server/ ("global"), examples/budget-allocator-server/ ("thread"), examples/sheet-music-server/ ("file") and examples/basic-host/ (host).

    For real-time dashboards or monitoring views, use an app-only tool (with visibility: ["app"]) that the App polls at regular intervals.

    Vanilla JS:

    let intervalId: number | null = null;

    async function poll() {
    const result = await app.callServerTool({
    name: "poll-data",
    arguments: {},
    });
    updateUI(result.structuredContent);
    }

    function startPolling() {
    if (intervalId !== null) return;
    poll();
    intervalId = window.setInterval(poll, 2000);
    }

    function stopPolling() {
    if (intervalId === null) return;
    clearInterval(intervalId);
    intervalId = null;
    }

    // Clean up when host tears down the view
    app.onteardown = async () => {
    stopPolling();
    return {};
    };

    React:

    useEffect(() => {
    if (!app) return;
    let cancelled = false;

    async function poll() {
    const result = await app!.callServerTool({
    name: "poll-data",
    arguments: {},
    });
    if (!cancelled) setData(result.structuredContent);
    }

    poll();
    const id = setInterval(poll, 2000);
    return () => {
    cancelled = true;
    clearInterval(id);
    };
    }, [app]);
    Note

    For a full example that implements this pattern, see: examples/system-monitor-server/.

    Some host platforms have size limits on tool call responses, so large files (PDFs, images, etc.) cannot be sent in a single response. Use an app-only tool with chunked responses to bypass these limits while keeping the data out of model context.

    Server-side: Register an app-only tool that returns data in chunks with pagination metadata:

    // Define the chunk response schema
    const DataChunkSchema = z.object({
    bytes: z.string(), // base64-encoded data
    offset: z.number(),
    byteCount: z.number(),
    totalBytes: z.number(),
    hasMore: z.boolean(),
    });

    const MAX_CHUNK_BYTES = 500 * 1024; // 500KB per chunk

    registerAppTool(
    server,
    "read_data_bytes",
    {
    title: "Read Data Bytes",
    description: "Load binary data in chunks",
    inputSchema: z.object({
    id: z.string().describe("Resource identifier"),
    offset: z.number().min(0).default(0).describe("Byte offset"),
    byteCount: z
    .number()
    .default(MAX_CHUNK_BYTES)
    .describe("Bytes to read"),
    }),
    outputSchema: DataChunkSchema,
    // Hidden from model - only callable by the App
    _meta: { ui: { visibility: ["app"] } },
    },
    async ({ id, offset, byteCount }): Promise<CallToolResult> => {
    const data = await loadData(id); // Your data loading logic
    const chunk = data.slice(offset, offset + byteCount);

    return {
    content: [{ type: "text", text: `${chunk.length} bytes at ${offset}` }],
    structuredContent: {
    bytes: Buffer.from(chunk).toString("base64"),
    offset,
    byteCount: chunk.length,
    totalBytes: data.length,
    hasMore: offset + chunk.length < data.length,
    },
    };
    },
    );

    Client-side: Loop calling the tool until all chunks are received:

    interface DataChunk {
    bytes: string; // base64
    offset: number;
    byteCount: number;
    totalBytes: number;
    hasMore: boolean;
    }

    async function loadDataInChunks(
    id: string,
    onProgress?: (loaded: number, total: number) => void,
    ): Promise<Uint8Array> {
    const CHUNK_SIZE = 500 * 1024; // 500KB chunks
    const chunks: Uint8Array[] = [];
    let offset = 0;
    let totalBytes = 0;
    let hasMore = true;

    while (hasMore) {
    const result = await app.callServerTool({
    name: "read_data_bytes",
    arguments: { id, offset, byteCount: CHUNK_SIZE },
    });

    if (result.isError || !result.structuredContent) {
    throw new Error("Failed to load data chunk");
    }

    const chunk = result.structuredContent as unknown as DataChunk;
    totalBytes = chunk.totalBytes;
    hasMore = chunk.hasMore;

    // Decode base64 to bytes
    const binaryString = atob(chunk.bytes);
    const bytes = new Uint8Array(binaryString.length);
    for (let i = 0; i < binaryString.length; i++) {
    bytes[i] = binaryString.charCodeAt(i);
    }
    chunks.push(bytes);

    offset += chunk.byteCount;
    onProgress?.(offset, totalBytes);
    }

    // Combine all chunks into single array
    const fullData = new Uint8Array(totalBytes);
    let pos = 0;
    for (const chunk of chunks) {
    fullData.set(chunk, pos);
    pos += chunk.length;
    }

    return fullData;
    }

    // Usage: load data with progress updates
    loadDataInChunks(resourceId, (loaded, total) => {
    console.log(`Loading: ${Math.round((loaded / total) * 100)}%`);
    }).then((data) => {
    console.log(`Loaded ${data.length} bytes`);
    });
    Note

    For a full example that implements this pattern, see: examples/pdf-server/.

    Server-side: Tool handler validates inputs and returns { isError: true, content: [...] }. The model receives this error through the normal tool call response.

    Client-side: If a runtime error occurs (e.g., API failure, permission denied, resource unavailable), use updateModelContext to inform the model:

    try {
    const _stream = await navigator.mediaDevices.getUserMedia({ audio: true });
    // ... use _stream for transcription
    } catch (err) {
    // Inform the model that the app is in a degraded state
    await app.updateModelContext({
    content: [
    {
    type: "text",
    text: "Error: transcription unavailable",
    },
    ],
    });
    }

    Binary content (e.g., video) can be served via MCP resources as base64-encoded blobs. The server returns the data in the blob field of the resource content, and the App fetches it via resources/read for use in the browser.

    Server-side: Register a resource that returns binary data in the blob field:

    server.registerResource(
    "Video",
    new ResourceTemplate("video://{id}", { list: undefined }),
    {
    description: "Video data served as base64 blob",
    mimeType: "video/mp4",
    },
    async (uri, { id }): Promise<ReadResourceResult> => {
    // Fetch or load your binary data
    const idString = Array.isArray(id) ? id[0] : id;
    const buffer = await getVideoData(idString);
    const blob = Buffer.from(buffer).toString("base64");

    return { contents: [{ uri: uri.href, mimeType: "video/mp4", blob }] };
    },
    );

    Client-side: Fetch the resource and convert the base64 blob to a data URI:

    const result = await app.readServerResource({ uri: `video://${videoId}` });

    const content = result.contents[0];
    if (!content || !("blob" in content)) {
    throw new Error("Resource did not contain blob data");
    }

    const videoEl = document.querySelector("video")!;
    videoEl.src = `data:${content.mimeType!};base64,${content.blob}`;
    Note

    For a full example that implements this pattern, see: examples/video-resource-server/.

    See the dedicated CSP & CORS guide in the Security section.

    The host provides context about its environment via McpUiHostContext. Use this to adapt your app's appearance and layout:

    • Theme — Use [data-theme="dark"] selectors or light-dark() function for theme-aware styles
    • CSS variables — Use var(--color-background-primary), etc. in your CSS (see McpUiStyleVariableKey for a full list)
    • Fonts — Use var(--font-sans) or var(--font-mono) with fallbacks (e.g., font-family: var(--font-sans, system-ui, sans-serif))
    • Safe area insets — Apply padding to avoid device notches, rounded corners, or system UI overlays

    Vanilla JS:

    function applyHostContext(ctx: McpUiHostContext) {
    if (ctx.theme) {
    applyDocumentTheme(ctx.theme);
    }
    if (ctx.styles?.variables) {
    applyHostStyleVariables(ctx.styles.variables);
    }
    if (ctx.styles?.css?.fonts) {
    applyHostFonts(ctx.styles.css.fonts);
    }
    if (ctx.safeAreaInsets) {
    mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`;
    mainEl.style.paddingRight = `${ctx.safeAreaInsets.right}px`;
    mainEl.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`;
    mainEl.style.paddingLeft = `${ctx.safeAreaInsets.left}px`;
    }
    }

    // Apply when host context changes
    app.onhostcontextchanged = applyHostContext;

    // Apply initial context after connecting
    app.connect().then(() => {
    const ctx = app.getHostContext();
    if (ctx) {
    applyHostContext(ctx);
    }
    });

    React:

    function MyApp() {
    const [hostContext, setHostContext] = useState<McpUiHostContext>();

    const { app } = useApp({
    appInfo: { name: "MyApp", version: "1.0.0" },
    capabilities: {},
    onAppCreated: (app) => {
    app.onhostcontextchanged = (ctx) => {
    setHostContext((prev) => ({ ...prev, ...ctx }));
    };
    },
    });

    // Set initial host context after connection
    useEffect(() => {
    if (app) {
    setHostContext(app.getHostContext());
    }
    }, [app]);

    // Apply styles when host context changes
    useEffect(() => {
    if (hostContext?.theme) {
    applyDocumentTheme(hostContext.theme);
    }
    if (hostContext?.styles?.variables) {
    applyHostStyleVariables(hostContext.styles.variables);
    }
    if (hostContext?.styles?.css?.fonts) {
    applyHostFonts(hostContext.styles.css.fonts);
    }
    }, [hostContext]);

    return (
    <div
    style={{
    background: "var(--color-background-primary)",
    fontFamily: "var(--font-sans)",
    paddingTop: hostContext?.safeAreaInsets?.top,
    paddingRight: hostContext?.safeAreaInsets?.right,
    paddingBottom: hostContext?.safeAreaInsets?.bottom,
    paddingLeft: hostContext?.safeAreaInsets?.left,
    }}
    >
    Styled with host CSS variables, fonts, and safe area insets
    </div>
    );
    }
    Note

    For full examples that implement this pattern, see: examples/basic-server-vanillajs/ and examples/basic-server-react/.

    Toggle fullscreen mode by calling requestDisplayMode:

    const container = document.getElementById("main")!;
    const ctx = app.getHostContext();
    const newMode = ctx?.displayMode === "inline" ? "fullscreen" : "inline";
    if (ctx?.availableDisplayModes?.includes(newMode)) {
    const result = await app.requestDisplayMode({ mode: newMode });
    container.classList.toggle("fullscreen", result.mode === "fullscreen");
    }

    Listen for display mode changes via onhostcontextchanged to update your UI:

    app.onhostcontextchanged = (ctx) => {
    // Adjust to current display mode
    if (ctx.displayMode) {
    const container = document.getElementById("main")!;
    const isFullscreen = ctx.displayMode === "fullscreen";
    container.classList.toggle("fullscreen", isFullscreen);
    }

    // Adjust display mode controls
    if (ctx.availableDisplayModes) {
    const fullscreenBtn = document.getElementById("fullscreen-btn")!;
    const canFullscreen = ctx.availableDisplayModes.includes("fullscreen");
    fullscreenBtn.style.display = canFullscreen ? "block" : "none";
    }
    };

    In fullscreen mode, remove the container's border radius so content extends to the viewport edges:

    #main {
    border-radius: var(--border-radius-lg);

    &.fullscreen {
    border-radius: 0;
    }
    }
    Note

    For full examples that implement this pattern, see: examples/shadertoy-server/, examples/pdf-server/, and examples/map-server/.

    Use updateModelContext to keep the model informed about what the user is viewing or interacting with. Structure the content with YAML frontmatter for easy parsing:

    const markdown = `---
    item-count: ${itemList.length}
    total-cost: ${totalCost}
    currency: ${currency}
    ---

    User is viewing their shopping cart with ${itemList.length} items selected:

    ${itemList.map((item) => `- ${item}`).join("\n")}`;

    await app.updateModelContext({
    content: [{ type: "text", text: markdown }],
    });
    Note

    For full examples that implement this pattern, see: examples/map-server/ and examples/transcript-server/.

    When you need to send more data than fits in a message, use updateModelContext to set the context first, then sendMessage with a brief prompt to trigger a response:

    const markdown = `---
    word-count: ${fullTranscript.split(/\s+/).length}
    speaker-names: ${speakerNames.join(", ")}
    ---

    ${fullTranscript}`;

    // Offload long transcript to model context
    await app.updateModelContext({ content: [{ type: "text", text: markdown }] });

    // Send brief trigger message
    await app.sendMessage({
    role: "user",
    content: [{ type: "text", text: "Summarize the key points" }],
    });
    Note

    For a full example that implements this pattern, see: examples/transcript-server/.

    For recoverable view state (e.g., current page in a PDF viewer, camera position in a map), use localStorage with a stable identifier provided by the server.

    Server-side: Tool handler generates a unique viewUUID and returns it in CallToolResult._meta.viewUUID:

    // In your tool callback, include viewUUID in the result metadata.
    return {
    content: [{ type: "text", text: `Displaying PDF viewer for "${title}"` }],
    structuredContent: { url, title, pageCount, initialPage: 1 },
    _meta: {
    viewUUID: randomUUID(),
    },
    };

    Client-side: Receive the UUID in ontoolresult and use it as the storage key:

    // Store the viewUUID received from the server
    let viewUUID: string | undefined;

    // Helper to save state to localStorage
    function saveState<T>(state: T): void {
    if (!viewUUID) return;
    try {
    localStorage.setItem(viewUUID, JSON.stringify(state));
    } catch (err) {
    console.error("Failed to save view state:", err);
    }
    }

    // Helper to load state from localStorage
    function loadState<T>(): T | null {
    if (!viewUUID) return null;
    try {
    const saved = localStorage.getItem(viewUUID);
    return saved ? (JSON.parse(saved) as T) : null;
    } catch (err) {
    console.error("Failed to load view state:", err);
    return null;
    }
    }

    // Receive viewUUID from the tool result
    app.ontoolresult = (result) => {
    viewUUID = result._meta?.viewUUID
    ? String(result._meta.viewUUID)
    : undefined;

    // Restore any previously saved state
    const savedState = loadState<{ currentPage: number }>();
    if (savedState) {
    // Apply restored state to your UI...
    }
    };

    // Call saveState() whenever your view state changes
    // e.g., saveState({ currentPage: 5 });

    For state that represents user effort (e.g., saved bookmarks, annotations, custom configurations), consider persisting it server-side using app-only tools instead. Pass the viewUUID to the app-only tool to scope the saved data to that view instance.

    Note

    For full examples using localStorage, see: examples/pdf-server/ (persists current page) and examples/map-server/ (persists camera position).

    Views with animations, WebGL rendering, or polling can consume significant CPU/GPU even when scrolled offscreen. Use IntersectionObserver to pause expensive operations when the view isn't visible:

    // Use IntersectionObserver to pause when view scrolls out of view
    const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
    if (entry.isIntersecting) {
    animation.play(); // or startPolling(), etc
    } else {
    animation.pause(); // or stopPolling(), etc
    }
    });
    });
    observer.observe(container);

    // Clean up when the host tears down the view
    app.onteardown = async () => {
    observer.disconnect();
    animation.pause();
    return {};
    };
    Note

    For full examples that implement this pattern, see: examples/shadertoy-server/ and examples/threejs-server/.

    Use ontoolinputpartial to receive streaming tool arguments as they arrive. This lets you show a loading preview before the complete input is available, such as streaming code into a <pre> tag before executing it, partially rendering a table as data arrives, or incrementally populating a chart.

    const codePreview = document.querySelector<HTMLPreElement>("#code-preview")!;
    const canvas = document.querySelector<HTMLCanvasElement>("#canvas")!;

    app.ontoolinputpartial = (params) => {
    codePreview.textContent = (params.arguments?.code as string) ?? "";
    codePreview.style.display = "block";
    canvas.style.display = "none";
    };

    app.ontoolinput = (params) => {
    codePreview.style.display = "none";
    canvas.style.display = "block";
    render(params.arguments?.code as string);
    };
    Important

    Partial arguments are "healed" JSON — the host closes unclosed brackets/braces to produce valid JSON. This means objects may be incomplete (e.g., the last item in an array may be truncated). Don't rely on partial data for critical operations; use it only for preview UI.

    Note

    For full examples that implement this pattern, see: examples/shadertoy-server/ and examples/threejs-server/.