# Add MCP Apps to your AI SDK application

**Author:** Ben Sabic

---

MCP Apps let a Model Context Protocol (MCP) tool return an interactive UI instead of plain text. The model still calls ordinary MCP tools, but a tool can point to a `ui://` resource that holds HTML, and your app renders that HTML in a sandboxed iframe. To build the host side, the AI SDK provides `@ai-sdk/mcp` helpers for advertising MCP Apps support, filtering which tools the model sees, and reading `ui://` resources, plus `@ai-sdk/react` components for rendering the iframe and bridging its messages. Your chat can then display a dashboard, form, or other interactive view generated by a tool, while the untrusted HTML remains isolated.

## Overview

In this guide, you'll learn how to:

- Connect to an MCP server with MCP Apps capabilities
  
- Pass only model-visible tools to the model with `splitMCPAppTools`
  
- Read a tool's `ui://` resource with `readMCPAppResource`
  
- Proxy app-initiated tool calls safely from the iframe
  
- Render the app UI in your React chat with `experimental_MCPAppRenderer`
  

## Prerequisites

Before you begin, make sure you have:

- The `ai` package with `@ai-sdk/mcp` and `@ai-sdk/react`
  
- The MCP TypeScript SDK (`@modelcontextprotocol/sdk`) and a provider package, such as `@ai-sdk/openai`
  
- An MCP server that exposes MCP Apps tools (tools that point to `ui://` resources)
  
- A React app that uses `useChat` (the examples below use the Next.js App Router)
  

## How an MCP Apps host works

An MCP Apps host connects to an MCP server, decides which tools the model can see, and renders any app UI that a tool points to. At runtime, the host follows these steps:

1. Connect to the MCP server with MCP Apps client capabilities.
   
2. List the server's tools and split them by MCP Apps visibility.
   
3. Pass only the model-visible tools to `streamText` or `generateText`.
   
4. Read a tool's `ui://` resource when its tool part includes MCP App metadata.
   
5. Render the HTML resource in a sandboxed iframe.
   
6. Proxy allowed iframe requests, such as app-visible tool calls, back to the MCP server.
   

The rest of this guide builds each step.

## Steps

### Connect to the MCP server with MCP Apps support

Create the MCP client with `mcpAppClientCapabilities` so the host advertises that it can render `text/html;profile=mcp-app` resources.

`import { createMCPClient, mcpAppClientCapabilities } from '@ai-sdk/mcp'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; export function createMCPAppsClient(origin: string) { return createMCPClient({ transport: new StreamableHTTPClientTransport(new URL('/mcp', origin)), clientName: 'my-mcp-apps-host', capabilities: mcpAppClientCapabilities, }); }`

Advertise these capabilities only if your host can safely fetch and render MCP App resources.

## Expose only model-visible tools

MCP Apps tools can declare `_meta.ui.visibility`. Pass tools marked `"model"` to the model, and keep tools marked only `"app"` for iframe requests so the model never sees them. Split the tool list with `splitMCPAppTools` and pass `modelVisible` to `streamText`.

`import { splitMCPAppTools } from '@ai-sdk/mcp'; import { convertToModelMessages, createUIMessageStreamResponse, streamText, toUIMessageStream, } from 'ai'; import { createMCPAppsClient } from './mcp-client'; import { openai } from '@ai-sdk/openai'; export async function POST(req: Request) { const requestUrl = new URL(req.url); const client = await createMCPAppsClient(requestUrl.origin); const { messages } = await req.json(); try { const definitions = await client.listTools(); const { modelVisible } = splitMCPAppTools(definitions); const tools = client.toolsFromDefinitions(modelVisible); const result = streamText({ model: openai('gpt-5.2'), tools, messages: await convertToModelMessages(messages), onEnd: async () => { await client.close(); }, }); return createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }), }); } catch (error) { await client.close(); throw error; } }`

When the model calls an app-backed tool, the MCP client keeps the app metadata in the tool UI, which the React renderer uses to determine whether a tool part has an MCP App.

## Read the app's UI resource

Read a tool's `ui://` resource with `readMCPAppResource` before you send it to the browser host.

`import { readMCPAppResource } from '@ai-sdk/mcp'; import { createMCPAppsClient } from '../../chat/mcp-client'; export async function POST(req: Request) { const requestUrl = new URL(req.url); const { uri } = await req.json(); const client = await createMCPAppsClient(requestUrl.origin); try { return Response.json(await readMCPAppResource({ client, uri })); } finally { await client.close(); } }`

`readMCPAppResource` checks that the resource uses a `ui://` URI, requires the MCP Apps MIME type, decodes text or base64 content, and returns the HTML along with rendering metadata such as its content security policy and permissions.

## Proxy app-visible tool calls

The iframe can't reach your MCP server directly. It sends JSON-RPC messages to your host, and your host decides which ones to allow. For an app-initiated tool call, confirm that the requested tool is app-visible before calling the MCP server.

`import { splitMCPAppTools } from '@ai-sdk/mcp'; import { createMCPAppsClient } from '../../chat/mcp-client'; export async function POST(req: Request) { const requestUrl = new URL(req.url); const { name, arguments: toolArguments } = await req.json(); const client = await createMCPAppsClient(requestUrl.origin); try { const { appVisible } = splitMCPAppTools(await client.listTools()); const isAllowed = appVisible.tools.some(tool => tool.name === name); if (!isAllowed) { return Response.json( { error: 'Tool is not app-visible' }, { status: 403 }, ); } return Response.json( await client.callTool({ name, arguments: toolArguments ?? {}, }), ); } finally { await client.close(); } }`

In production, add any policy and user-approval checks your app needs before forwarding an iframe request.

## Render the app in your React chat

In your React chat UI, render normal message parts as usual and pass tool parts to `experimental_MCPAppRenderer`.

> `experimental_MCPAppRenderer` is experimental and may change in a future release.

`'use client'; import { experimental_MCPAppRenderer as MCPAppRenderer, useChat, type MCPAppBridgeHandlers, type MCPAppMetadata, type MCPAppResource, type MCPAppSandboxConfig, } from '@ai-sdk/react'; import { DefaultChatTransport, isToolUIPart } from 'ai'; const sandbox = { url: '/mcp-app-sandbox', className: 'h-80 w-full rounded-lg border', style: { border: 0 }, } satisfies MCPAppSandboxConfig; async function loadResource(app: MCPAppMetadata): Promise<MCPAppResource> { const response = await fetch('/api/mcp-app-host/read-resource', { method: 'POST', body: JSON.stringify({ uri: app.resourceUri }), }); if (!response.ok) { throw new Error('Failed to load MCP App resource'); } return response.json(); } const handlers: MCPAppBridgeHandlers = { callTool: params => fetch('/api/mcp-app-host/call-tool', { method: 'POST', body: JSON.stringify(params), }).then(response => response.json()), openLink: ({ url }) => { window.open(url, '_blank', 'noopener,noreferrer'); return {}; }, }; export default function Chat() { const { messages, sendMessage } = useChat({ transport: new DefaultChatTransport({ api: '/api/chat' }), }); return ( <> {messages.map(message => message.parts.map((part, index) => { if (part.type === 'text') { return <div key={index}>{part.text}</div>; } if (isToolUIPart(part)) { return ( <MCPAppRenderer key={part.toolCallId} part={part} loadResource={loadResource} handlers={handlers} sandbox={sandbox} fallback={<div>Loading MCP App...</div>} /> ); } return null; }), )} <button onClick={() => sendMessage({ text: 'Show me a dashboard' })}> Send </button> </> ); }`

`experimental_MCPAppRenderer` renders nothing for ordinary tools. For an app-backed tool, it loads the resource, creates the sandbox bridge, sends tool input and result notifications to the iframe, and forwards supported app requests through your handlers.

## Best practices

- Treat MCP App HTML as untrusted. Render it in a sandboxed iframe, ideally through a sandbox proxy route on a separate origin.
  
- Never pass app-only tools to the model. Use `splitMCPAppTools` and expose only the `modelVisible` tools.
  
- Validate every iframe request on the server before you call `client.callTool`.
  
- Cache resources by `resourceUri` so repeated tool calls don't refetch identical HTML.
  
- Keep each tool's `content` and `structuredContent` useful on their own, so text-only hosts still work without the UI.
  
- Close short-lived MCP clients when the response or host request finishes.
  

## Next steps

- Read the [MCP Apps helpers reference](https://ai-sdk.dev/v7/docs/reference/ai-sdk-core/mcp-apps) for the host-side functions.
  
- See the [MCP App Renderer reference](https://ai-sdk.dev/v7/docs/reference/ai-sdk-ui/mcp-app-renderer) for the React component's props.
  
- Learn more about setting up the [underlying MCP tools](https://ai-sdk.dev/docs/ai-sdk-core/mcp-tools).

---

[View full KB sitemap](/kb/sitemap.md)
