- MCP Server ToolsUpdated
- Managed MCP with chat()Updated
- Manual MCPUpdated
- MCP Type GenerationUpdated
- MCP Client InputUpdated
- MCP Server Tools: AdvancedNew
- MCP EventsNew
- MCP AppsUpdated
- Serve Tools over HTTPUpdated
- Serve Resources and PromptsUpdated
- Ask for InputUpdated
- Draft Text from a ToolUpdated
- MCP Server TasksUpdated
- MCP Server AuthUpdated
- MCP Server SessionsUpdated
- MCP Server on stdioUpdated
Persistence (Advanced)
- GenerationsUpdated
- Realtime Voice ChatUpdated
- Text-to-SpeechUpdated
- TranscriptionUpdated
- Transcription: AdvancedNew
- Voice CreationUpdated
- Audio RecordingUpdated
- Audio GenerationUpdated
- Image GenerationUpdated
- Image Generation: AdvancedNew
- Video GenerationUpdated
- Video Generation: AdvancedNew
- Live GenerationUpdated
- World GenerationUpdated
- Generation HooksUpdated
- Build your first harnessUpdated
- Host optionsNew
- Send and show mediaUpdated
- Connect clientsUpdated
- Write a client in any languageNew
- Access and tenantsNew
- Run in the terminalUpdated
- Deploy a harnessUpdated
- Send inputs safelyUpdated
- Run a tool without a model
- Durable sessionsUpdated
- Run background work on workersNew
- List, rename, and fork sessionsUpdated
- Lifecycle and retentionNew
- Run side effects onceUpdated
- Wait for a personNew
- Build on the session logUpdated
- Control how a turn endsUpdated
- Store settings per threadUpdated
- Compact a long sessionUpdated
- Fork and reset a threadUpdated
- One log for a session treeUpdated
- Write a pluginUpdated
- Track usage and costUpdated
- Ask before risky tool callsUpdated
- Give your agent coding toolsUpdated
- Sandbox and extend the coding toolsUpdated
- Run the coding tools for many threadsNew
- Undo a turnUpdated
- Build a coding agentUpdated
- Delegate to coding agentsUpdated
- Switch between agent profilesUpdated
- Auth and connectorsUpdated
- Share a thread between peopleUpdated
- Connect model providersUpdated
- Build your own UIUpdated
- Code mode in a harnessUpdated
- Work until a goal is metUpdated
- Run agents from a harnessUpdated
- Hand off a conversationNew
- Add skillsUpdated
- Use MCP serversUpdated
- Give your agent a computerNew
- Use from any MCP clientUpdated
- Use a harness from an editor (ACP)Updated
- OverviewUpdated
- Quick StartUpdated
- ProvidersUpdated
- HarnessesUpdated
- Harness AuthUpdated
- WorkspaceUpdated
- ToolsUpdated
- PolicyUpdated
- Lifecycle & SnapshotsUpdated
- Portable SnapshotsUpdated
- Keep Files After ReloadUpdated
- Save a Named VersionUpdated
- Branch From a VersionUpdated
- Send a Frozen FileUpdated
- Let the Agent Save and ForkUpdated
- Pick Which Files to KeepUpdated
- What a Snapshot StoresUpdated
- Instance DurabilityUpdated
- Durable RunsUpdated
- EventsUpdated
Sandboxes (Advanced)
- Runtime ContextUpdated
- Bring Your Own KeyUpdated
- Keep Keys on the ServerNew
- Debug LoggingUpdated
- Multimodal ContentUpdated
- Files APIUpdated
- Per-Model Type SafetyUpdated
- Runtime Adapter SwitchingUpdated
- Tree-ShakingUpdated
- Extend AdapterUpdated
- Extend Adapter: AdvancedNew
- Typed Pre-Configured OptionsUpdated
- Test with a Fake ModelUpdated
- Prompt CachingUpdated
- Mid-Conversation ChangesUpdated
- Approval Flow ProcessingUpdated
MCP Apps
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.
Quick setup#
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.
- Install the widget renderer:
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- Connect your chat route to the MCP server. You need no extra server code for widgets:
// 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.
How it works#
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 model calls the tool during the chat() run.
- TanStack AI reads the ui:// resource of that tool.
- It adds a UIResourcePart to the parts of the assistant message, next to the normal tool result. The part never goes to the model.
- MCPAppResource loads your sandbox page in an isolated iframe, and renders the widget inside it.
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.
Make a widget interactive#
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:
- Server: a route with createMcpAppCallHandler. The widget calls this route to run a tool.
- Client: a bridge from useMcpAppBridge. It sends each widget action to the right place.
- Install the packages:
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- In your chat route, use createMCPClients. The key (weather) becomes the serverId on each widget part:
// 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)
},
},
},
})- Add the call handler route. Use the same key as in the chat route:
// 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' },
})
},
},
},
})- In the client, create a bridge and pass it to MCPAppResource:
// 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:
- tool actions: a POST to callEndpoint.
- prompt actions: chat.sendMessage(prompt).
- link actions: onLink(url), if you pass it.
Framework support#
MCPAppResource and useMcpAppBridge ship for React and Preact:
- React: @tanstack/ai-react/mcp-apps.
- Preact: @tanstack/ai-preact/mcp-apps. It needs a preact/compat alias.
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.
Advanced#
The UIResourcePart shape#
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.
What the call handler allows#
createMcpAppCallHandler accepts a single MCPClient, an MCPClients pool, or an array of either. For each call, it:
- Reads the transport descriptor of the client with client.getInfo() or pool.getServers(). This needs no live socket.
- Connects to the MCP server again for this call. This works on serverless hosts.
- Makes sure that the server exposes toolName. If it does not, the result is { ok: false, error: "Tool not allowed: <name>" }, and the tool does not run. You cannot turn this check off.
- Runs the tool and returns { ok: true, result } or { ok: false, error }.
The handler also uses the tool policy of each client:
- toolFilter: a tool that the filter hides returns Tool not allowed: <name>.
- needsApproval: a widget call has no approval step. A tool that needsApproval marks returns { ok: false, error: "Tool needs approval: <name>" }.
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',
})Log call failures#
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.
Links from a widget#
- If you pass no onLink, the bridge drops the link and logs a warning. openLink returns { isError: true }, and the call does not hang.
- With onLink, the bridge forwards only http:, https:, and mailto: URLs. It always rejects other schemes (javascript:, data:, file:) before your handler runs.
More than one server#
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).
Widget calls and chat history#
A widget tool call does not add to the chat history of the thread. Each widget action stands alone.
Keep sessions for stateful servers#
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 })- When the store has an entry for a thread and a serverId, the store wins. On a miss, the handler uses clients.
- ttlMs is optional. The default is 30 minutes.
- inMemoryMcpSessionStore lives in one Node.js process. It does not survive a serverless restart, and replicas do not share it. To share sessions, write your own store with the McpSessionStore interface.
API reference#
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. |