An MCP App is a widget returned with a tool result. Render that widget in the chat next to the text.
TanStack AI reads the widget during the chat() run and puts it on the assistant message as a ui-resource part. Render that part with MCPAppResource.
You need a chat with an MCP server first. If you do not have one, start with MCP Server Tools. This setup uses React. Preact has the same API.
npm i @tanstack/ai-mcp @mcp-ui/clientpnpm add @tanstack/ai-mcp @mcp-ui/clientyarn add @tanstack/ai-mcp @mcp-ui/clientbun add @tanstack/ai-mcp @mcp-ui/client// src/routes/api.chat.ts (TanStack Start)
import { createFileRoute } from '@tanstack/react-router'
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { createMCPClient } from '@tanstack/ai-mcp'
export const Route = createFileRoute('/api/chat')({
server: {
handlers: {
POST: async ({ request }) => {
const { messages, threadId } = await request.json()
const mcp = await createMCPClient({
transport: {
type: 'http',
url: process.env.MCP_URL ?? '',
},
})
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages,
threadId,
mcp: { clients: [mcp] },
})
return toServerSentEventsResponse(stream)
},
},
},
})Host a sandbox proxy page on your own origin, for example /mcp-sandbox.html. The @mcp-ui/client docs have this page.
Render each ui-resource part with MCPAppResource:
// src/components/Chat.tsx
import { useChat } from '@tanstack/ai-react'
import { fetchServerSentEvents } from '@tanstack/ai-client'
import { MCPAppResource } from '@tanstack/ai-react/mcp-apps'
export function Chat() {
const { messages, sendMessage, status } = useChat({
connection: fetchServerSentEvents('/api/chat'),
threadId: 'support-chat',
})
return (
<div>
{messages.map((m) => (
<div key={m.id}>
{m.parts.map((part, i) => {
if (part.type === 'text') {
return <p key={i}>{part.content}</p>
}
if (part.type === 'ui-resource') {
return (
<MCPAppResource
key={i}
part={part}
sandbox={{ url: new URL('https://your-app.example.com/mcp-sandbox.html') }}
/>
)
}
return null
})}
</div>
))}
<button
onClick={() => sendMessage({ content: 'Show me the weather widget' })}
disabled={status === 'streaming'}
>
Send
</button>
</div>
)
}Send the message. When the model calls a tool that has a widget, the widget shows in the chat.
MCP Apps is an MCP extension, standardized on 2026-01-26. An MCP server links a tool to a ui:// resource that holds the widget.
The sandbox URL is the security boundary. It is the same for every widget, so you set it once, at deploy time. The widget HTML comes from part.resource, not from this URL.
Without a bridge prop, the widget is display only. A click in the widget that calls a tool or sends a prompt does nothing. To make it work, see the next section.
To link a tool to its widget on your own TanStack MCP server, see Tell the host what a tool does.
A widget can call tools on its MCP server, send a prompt to the model, or open a link. For that, you add two pieces:
npm i @tanstack/ai-mcp @tanstack/ai-client @mcp-ui/clientpnpm add @tanstack/ai-mcp @tanstack/ai-client @mcp-ui/clientyarn add @tanstack/ai-mcp @tanstack/ai-client @mcp-ui/clientbun add @tanstack/ai-mcp @tanstack/ai-client @mcp-ui/client// src/routes/api.chat.ts
import { createFileRoute } from '@tanstack/react-router'
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { createMCPClients } from '@tanstack/ai-mcp'
export const Route = createFileRoute('/api/chat')({
server: {
handlers: {
POST: async ({ request }) => {
const body = await request.json()
const pool = await createMCPClients({
weather: {
transport: { type: 'http', url: process.env.WEATHER_MCP_URL ?? '' },
},
})
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages: body.messages,
threadId: body.threadId,
mcp: { clients: [pool] },
})
return toServerSentEventsResponse(stream)
},
},
},
})// src/routes/api.mcp-apps-call.ts (TanStack Start)
import { createFileRoute } from '@tanstack/react-router'
import { createMCPClients } from '@tanstack/ai-mcp'
import { createMcpAppCallHandler } from '@tanstack/ai-mcp/apps'
const mcp = await createMCPClients({
weather: {
transport: {
type: 'http',
url: process.env.WEATHER_MCP_URL ?? '',
headers: { Authorization: `Bearer ${process.env.WEATHER_MCP_TOKEN ?? ''}` },
},
},
})
const handler = createMcpAppCallHandler({ clients: mcp })
export const Route = createFileRoute('/api/mcp-apps/call')({
server: {
handlers: {
POST: async ({ request }) => {
// body: { threadId, serverId, toolName, args?, messageId? }
const body = await request.json()
const result = await handler(body)
return new Response(JSON.stringify(result), {
headers: { 'Content-Type': 'application/json' },
})
},
},
},
})// src/components/Chat.tsx
import { useChat, useMcpAppBridge } from '@tanstack/ai-react'
import { fetchServerSentEvents } from '@tanstack/ai-client'
import { MCPAppResource } from '@tanstack/ai-react/mcp-apps'
export function Chat() {
// A stable id that links widget calls to this conversation.
const threadId = 'weather-chat'
const { messages, sendMessage, status } = useChat({
connection: fetchServerSentEvents('/api/chat'),
threadId,
})
const bridge = useMcpAppBridge({
threadId,
callEndpoint: '/api/mcp-apps/call',
chat: { sendMessage: (content, body) => sendMessage(content, { body }) },
// Without onLink, the bridge blocks links.
onLink: (url) => window.open(url, '_blank', 'noopener'),
})
return (
<div>
{messages.map((m) => (
<div key={m.id}>
{m.parts.map((part, i) => {
if (part.type === 'text') {
return <p key={i}>{part.content}</p>
}
if (part.type === 'ui-resource') {
return (
<MCPAppResource
key={i}
part={part}
bridge={bridge}
sandbox={{ url: new URL('https://your-app.example.com/mcp-sandbox.html') }}
/>
)
}
return null
})}
</div>
))}
<button
onClick={() => sendMessage({ content: 'Show me the weather widget' })}
disabled={status === 'streaming'}
>
Send
</button>
</div>
)
}Now a click in the widget runs the tool on the MCP server, and the widget shows the result. A prompt from the widget goes to the chat as a new message.
The bridge sends each widget action to one place:
MCPAppResource and useMcpAppBridge ship for React and Preact:
Solid, Vue, Svelte, and Angular have no widget component yet. The AppRenderer of @mcp-ui/client v7 is React-only. createMcpAppBridge from @tanstack/ai-client works in any framework.
import type { UIResourcePart } from '@tanstack/ai'
// {
// type: 'ui-resource'
// resource: { uri: string; mimeType: string; text?: string; blob?: string }
// serverId?: string // pool prefix or config key. Routes interactive calls.
// toolCallId: string // links the part to its tool call
// toolName: string // MCP tool name whose UI this resource renders
// meta?: Record<string, unknown> // reserved. Always undefined for now.
// }TanStack AI reads the resource during the chat run. If the read fails (a network error, a missing resource), the tool result still goes to the model, and the message has no widget.
createMcpAppCallHandler accepts a single MCPClient, an MCPClients pool, or an array of either. For each call, it:
The handler also uses the tool policy of each client:
To limit the tools more, pass allowTool. A request must pass both the server check and allowTool:
import { createMCPClients } from '@tanstack/ai-mcp'
import { createMcpAppCallHandler } from '@tanstack/ai-mcp/apps'
const mcp = await createMCPClients({
weather: { transport: { type: 'http', url: process.env.MCP_URL ?? '' } },
})
const handler = createMcpAppCallHandler({
clients: mcp,
// The server can expose more tools. Only this one goes through.
allowTool: (req) => req.toolName === 'place_order',
})The handler returns { ok: false, error } to the widget and logs nothing. To see why a call failed, pass onError. The handler calls it (and awaits it) before it returns the result:
import { createMCPClients } from '@tanstack/ai-mcp'
import { createMcpAppCallHandler } from '@tanstack/ai-mcp/apps'
const mcp = await createMCPClients({
weather: { transport: { type: 'http', url: process.env.MCP_URL ?? '' } },
})
const handler = createMcpAppCallHandler({
clients: mcp,
onError: (error, { phase, req }) => {
console.error('MCP app call failed', phase, req.toolName, error)
},
})phase is 'call' for a failure to connect, find, or run the tool. It is 'close' for a failure to close the client after the call. A 'close' failure does not change the result.
Interactive calls go to the server whose prefix matches the serverId of the part. createMCPClients sets the prefix of each server to its config key by default.
If you set prefix: '' on one server in a pool of many, that server has no serverId. Its widgets cannot make interactive calls. Give each interactive server its own prefix (the default is fine).
A widget tool call does not add to the chat history of the thread. Each widget action stands alone.
By default the handler connects again for each widget action. If your MCP server needs a session that lasts, pass a session store:
import { createMCPClients } from '@tanstack/ai-mcp'
import {
createMcpAppCallHandler,
inMemoryMcpSessionStore,
} from '@tanstack/ai-mcp/apps'
const mcp = await createMCPClients({
weather: { transport: { type: 'http', url: process.env.MCP_URL ?? '' } },
})
const store = inMemoryMcpSessionStore({ ttlMs: 30 * 60_000 })
const handler = createMcpAppCallHandler({ clients: mcp, store })createMcpAppCallHandler(options) from @tanstack/ai-mcp/apps returns (req) => Promise<{ ok: true; result: unknown } | { ok: false; error: string }>.
| Option | Type | Description |
|---|---|---|
| clients | MCPClient, MCPClients, or an array of either | The clients that you pass to chat(). Required. |
| store | McpSessionStore | Session store for stateful servers. |
| allowTool | (req) => boolean | Promise<boolean> | An extra check on top of the server check. |
| onError | (error, { phase, req }) => void | Promise<void> | Called with each failure. |
createMcpAppBridge(options) from @tanstack/ai-client returns an McpAppBridge with callTool, sendPrompt, and openLink. useMcpAppBridge from @tanstack/ai-react and @tanstack/ai-preact takes the same options. It returns a bridge that stays the same for one threadId and callEndpoint, and it always calls your latest sendMessage and onLink.
| Option | Type | Description |
|---|---|---|
| threadId | string | Names the thread for the call handler. Required. |
| callEndpoint | string | The POST route of the call handler. Required. |
| chat.sendMessage | (content, body?) => Promise<void> | Sends a prompt from the widget. Required. |
| fetchImpl | typeof fetch | Replaces fetch, for example in tests. |
| onLink | (url) => void | Opens a link. Without it, links are dropped. |
MCPAppResource from @tanstack/ai-react/mcp-apps or @tanstack/ai-preact/mcp-apps:
| Prop | Type | Description |
|---|---|---|
| part | UIResourcePart | The ui-resource part from the assistant message. Required. |
| sandbox | { url: URL } | The URL of your sandbox proxy page. Required. |
| bridge | McpAppBridge | Omit it for a display-only widget. |
| toolInput | Record<string, unknown> | Tool input that the widget gets when it is ready. |